@markii/host 0.17.0-next.3 → 0.17.0-next.5

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 (55) hide show
  1. package/README.md +34 -1
  2. package/dist/browser-worker.classic.js +640 -0
  3. package/dist/browser-worker.js +19 -19
  4. package/dist/browser.d.ts +1 -1
  5. package/dist/browser.js +10 -3
  6. package/dist/conformance/host/05-export-html-pack-directive/expected.json +2 -0
  7. package/dist/conformance/host/06-install-pack-then-render/expected.json +6 -1
  8. package/dist/conformance/host/17-export-linked-set/expected.json +17 -0
  9. package/dist/conformance/host/17-export-linked-set/files/second.mk.md +3 -0
  10. package/dist/conformance/host/17-export-linked-set/note.mk.md +4 -0
  11. package/dist/conformance/host/17-export-linked-set/scenario.json +6 -0
  12. package/dist/conformance/host/README.md +17 -12
  13. package/dist/diagnostics/render-diagnostics.d.ts +1 -1
  14. package/dist/export/note-export.d.ts +1 -1
  15. package/dist/host/adapter.d.ts +13 -3
  16. package/dist/host/cascade-export-behavior.d.ts +24 -8
  17. package/dist/host/cascade-export-behavior.js +61 -1
  18. package/dist/host/conformance-runner.d.ts +3 -4
  19. package/dist/host/conformance-runner.js +58 -8
  20. package/dist/host/create-host.d.ts +65 -8
  21. package/dist/host/create-host.js +54 -23
  22. package/dist/host/export-behavior.d.ts +2 -1
  23. package/dist/host/export-behavior.js +1 -1
  24. package/dist/host/labels.d.ts +5 -14
  25. package/dist/host/labels.js +8 -22
  26. package/dist/host/pack-archive.d.ts +4 -10
  27. package/dist/host/pack-archive.js +11 -14
  28. package/dist/host/pack-install.d.ts +10 -21
  29. package/dist/host/pack-install.js +22 -16
  30. package/dist/host/pack-trust-behavior.js +1 -1
  31. package/dist/host/run-behavior.d.ts +4 -0
  32. package/dist/host/run-behavior.js +9 -1
  33. package/dist/host/terminal-packs.d.ts +1 -1
  34. package/dist/html-engine.js +4 -4
  35. package/dist/index.d.ts +19 -81
  36. package/dist/index.js +19 -62
  37. package/dist/internal.d.ts +28 -0
  38. package/dist/internal.js +31 -0
  39. package/dist/node-fs.d.ts +0 -3
  40. package/dist/node-fs.js +1 -21
  41. package/dist/node-worker.d.ts +8 -0
  42. package/dist/node-worker.js +36 -15
  43. package/dist/node.d.ts +1 -2
  44. package/dist/node.js +4 -3
  45. package/dist/packs/discover.d.ts +2 -0
  46. package/dist/packs/discover.js +1 -0
  47. package/dist/run/bundle-run.d.ts +1 -1
  48. package/dist/run/bundle-run.js +1 -1
  49. package/dist/run/net-bridge.js +7 -5
  50. package/dist/run/net-pinning.d.ts +2 -0
  51. package/dist/run/net-pinning.js +17 -3
  52. package/dist/run/net-provider.js +23 -12
  53. package/dist/run/net-refusal.d.ts +4 -0
  54. package/dist/run/net-refusal.js +24 -0
  55. package/package.json +28 -12
package/dist/browser.d.ts CHANGED
@@ -11,7 +11,7 @@ export interface FetchNetProviderOptions {
11
11
  * every request. Entries that do not parse as an http(s) URL are ignored.
12
12
  */
13
13
  allowedOrigins?: readonly string[];
14
- /** How a policy refusal is built. Defaults to a plain `Error`; the isolate bridge marks every refusal as a denial on the wire either way. */
14
+ /** How a policy refusal is built. Defaults to a plain `Error`. The provider marks every refusal it throws, so the isolate bridge sends it as a denial. A transport failure is never marked. */
15
15
  denial?: (message: string) => Error;
16
16
  /** The `fetch` to use. Defaults to the global one; injectable for tests. */
17
17
  fetch?: typeof fetch;
package/dist/browser.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // `new URL(..., import.meta.url)` references below resolve in a consumer's
15
15
  // bundler without configuration where that bundler treats them as assets.
16
16
  import { createBrowserIsolate } from './run/browser-isolate.js';
17
+ import { netRefusal } from './run/net-refusal.js';
17
18
  function originOf(entry) {
18
19
  try {
19
20
  const url = new URL(entry);
@@ -82,7 +83,10 @@ export function createFetchNetProvider(options = {}) {
82
83
  const allowed = new Set((options.allowedOrigins ?? [])
83
84
  .map(originOf)
84
85
  .filter((origin) => origin !== undefined));
85
- const denial = options.denial ?? ((message) => new Error(message));
86
+ const build = options.denial ?? ((message) => new Error(message));
87
+ // Every policy refusal is marked so the net bridge can tell it from a
88
+ // transport failure (a plain `Error`).
89
+ const denial = (message) => netRefusal(build(message));
86
90
  return (netAllowlist, maxFetchBytes, _policy, signal) => {
87
91
  // The run's granted hosts, re-checked here on the page side: the worker
88
92
  // checks them too, but the worker is the isolate a hostile script runs
@@ -110,7 +114,7 @@ export function createFetchNetProvider(options = {}) {
110
114
  throw denial(`origin "${url.origin}" is not on the host's allowlist`);
111
115
  }
112
116
  if (signal?.aborted)
113
- throw denial('the run has ended');
117
+ throw new Error('the run has ended');
114
118
  const doFetch = options.fetch ?? fetch;
115
119
  let response;
116
120
  try {
@@ -126,7 +130,10 @@ export function createFetchNetProvider(options = {}) {
126
130
  });
127
131
  }
128
132
  catch (error) {
129
- throw denial(`request failed: ${error instanceof Error ? error.message : String(error)}`);
133
+ // A transport failure (network down, connection refused, CORS,
134
+ // abort): an ordinary runtime failure with the reason, not a policy
135
+ // refusal.
136
+ throw new Error(`request failed: ${error instanceof Error ? error.message : String(error)}`);
130
137
  }
131
138
  if (response.type === 'opaqueredirect') {
132
139
  throw denial('redirects are not followed in a browser');
@@ -2,6 +2,8 @@
2
2
  "outcomes": [
3
3
  {
4
4
  "kind": "exported",
5
+ "valueCount": 0,
6
+ "renderEngine": "static",
5
7
  "contains": ["mk-unknown", "ana_timeline"],
6
8
  "excludes": ["<script"]
7
9
  }
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "outcomes": [
3
3
  { "kind": "opened" },
4
- { "kind": "installed", "namespace": "ana", "replaced": false },
4
+ {
5
+ "kind": "installed",
6
+ "namespace": "ana",
7
+ "replaced": false,
8
+ "installedDirName": "ana"
9
+ },
5
10
  { "kind": "loaded", "namespaces": ["ana"] },
6
11
  { "kind": "installed", "namespace": "ana", "replaced": true }
7
12
  ],
@@ -0,0 +1,17 @@
1
+ {
2
+ "outcomes": [
3
+ {
4
+ "kind": "exported",
5
+ "noteCount": 2,
6
+ "entries": ["index.html", "note.html", "second.html"]
7
+ }
8
+ ],
9
+ "diagnostics": [
10
+ "Cascade export wrote",
11
+ "Exported",
12
+ "Rendered with the static engine because no pack components are loaded",
13
+ "Exported",
14
+ "Rendered with the static engine because no pack components are loaded"
15
+ ],
16
+ "prompts": []
17
+ }
@@ -0,0 +1,3 @@
1
+ # Second note
2
+
3
+ A page that the root note links to.
@@ -0,0 +1,4 @@
1
+ # Linked set
2
+
3
+ This note links to [a second note](files/second.mk.md) and to
4
+ [a page outside the set](https://example.com/page).
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "export-linked-set",
3
+ "invariant": "a note and the notes it links to export as one archive with an index page, and a host that does not offer the export reports it unsupported",
4
+ "clock": 1700000000000,
5
+ "actions": [{ "type": "exportLinkedSet" }]
6
+ }
@@ -49,6 +49,7 @@ needs, is the runner's own `ACTION_CAPABILITY` map:
49
49
  | `run` | `isolate` |
50
50
  | `exportHtml` | `export-format` (`html`) |
51
51
  | `exportAnsi` | `export-format` (`ansi`) |
52
+ | `exportLinkedSet` | `linked-set-export` (a `resolveLinkedSetTarget`) |
52
53
  | `installPack` | `packs` (and an `installRoot`) |
53
54
  | `loadPacks` | `packs` |
54
55
  | `removePack` | `packs` (and an `installRoot` plus `removeFolder`) |
@@ -97,12 +98,12 @@ would have produced:
97
98
  and persists exactly what it always does. Only the isolate that would
98
99
  execute the script is faked.
99
100
  - `requiresManualTier`: the run reports `tier-blocked` under any trigger
100
- but `'manual'`, regardless of grants — the same enforcement the real
101
+ but `'manual'`, regardless of grants, the same enforcement the real
101
102
  sandbox applies to an effectful call under a read-only trigger.
102
103
 
103
104
  Faking the isolate keeps this corpus fast and deterministic. It proves the
104
- HOST-LEVEL plumbing — grant prompts, wording, capability declarations,
105
- trigger forwarding — is identical across hosts. Real sandbox enforcement
105
+ HOST-LEVEL plumbing, grant prompts, wording, capability declarations,
106
+ trigger forwarding, is identical across hosts. Real sandbox enforcement
106
107
  (the tier gate, the net allowlist, the marshal boundary) is
107
108
  `conformance/executor/`'s job and is not duplicated here.
108
109
 
@@ -137,9 +138,13 @@ coarse, matching each `kind`:
137
138
  taxonomy only, never a raw message or a timing; an optional `traceOk`
138
139
  compares the recorded run trace's `ok` flag.
139
140
  - `exported`: `contains`/`excludes` are substrings checked against the
140
- written file's content, never the whole document — the render corpora
141
- already pin bytes.
142
- - `installed`: `namespace` and `replaced`.
141
+ written file's content, never the whole document, the render corpora
142
+ already pin bytes. `valueCount` is how many last-run values were baked
143
+ in, and `renderEngine` (`static` or `react`) is which engine drew the body.
144
+ A linked-set export is an `exported` outcome too: `noteCount` is how many
145
+ notes the archive holds, and `entries` is its sorted file names.
146
+ - `installed`: `namespace` and `replaced`, and `installedDirName`, the name
147
+ of the folder the pack was unzipped into.
143
148
  - `declined`: `step` (`'consent'`/`'replace'`, or absent for the
144
149
  scripts-disabled synthetic outcome) and `reasonContains`, a substring of
145
150
  the user-facing reason.
@@ -159,7 +164,7 @@ coarse, matching each `kind`:
159
164
 
160
165
  An action whose capability the adapter under test does not declare is
161
166
  never diffed against `expected.json` at all (see "Capability handling"
162
- below) — `expected.json` always describes the FULLY CAPABLE host's
167
+ below), `expected.json` always describes the FULLY CAPABLE host's
163
168
  correct behavior, even for a scenario where one real host today has
164
169
  nothing to compare it to.
165
170
 
@@ -171,11 +176,11 @@ difference) without the corpus having to spell out three wordings for
171
176
  one behavior. Diagnostics lines written by a capability decline itself
172
177
  (`declined (<capability> unsupported on <host>): …`) are filtered out of
173
178
  the actual list before this comparison, since that line exists only
174
- because the capability was ABSENT — it is not part of what
179
+ because the capability was ABSENT, it is not part of what
175
180
  `expected.json` describes.
176
181
 
177
182
  `prompts` is load-bearing: it proves all three hosts ask the same
178
- questions, worded the same way, in the same order — the single most
183
+ questions, worded the same way, in the same order, the single most
179
184
  security-relevant parity property in this batch.
180
185
 
181
186
  ## Capability handling (the crux)
@@ -215,7 +220,7 @@ rather than a skipped test or a faked empty answer.
215
220
  | `14-remove-pack` | removing an installed pack deletes it; removing it again reports it is not installed |
216
221
  | `15-load-packs-engine-views` | loading packs reports every installed component and, as `unavailable`, the ones this host's engine has no view for |
217
222
  | `16-pack-trust` | a folder that arrives without consent is listed as present and never loaded; enable, disable, re-enable and remove move the device trust list; a host without the list reports the whole scenario `unsupported` |
223
+ | `17-export-linked-set` | a note and the notes it links to export as one archive with an index page; a host with no linked-set target reports `unsupported` |
218
224
 
219
- See `tmp/W11-phase3.md` for which capabilities each of the three real
220
- adapters currently declares, and which scenarios are consequently
221
- `unsupported` on every host today (a reported gap, not a corpus defect).
225
+ The capabilities an adapter declares decide which scenarios report
226
+ `unsupported` on that host. That is a reported gap and not a corpus defect.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The one place a render-time diagnostic event becomes a line on a host's
3
- * diagnostics surface (docs/integration.md's host checklist, item 9).
3
+ * diagnostics surface (docs/hosts.md's host checklist, item 9).
4
4
  *
5
5
  * Both renderers report a quiet in-page marker through their `onDiagnostic`
6
6
  * render option (`@markii/stdlib`'s `DiagnosticEvent`). The marker alone
@@ -71,7 +71,7 @@ export declare const EXPORT_HIDE_SCRIPT_BLOCKS_CLASS = "mk-export--hide-scripts"
71
71
  export declare const EXPORT_PAGE_CSS = "\nbody {\n margin: 0;\n background: #fff;\n}\n.doc {\n box-sizing: border-box;\n max-width: 46rem;\n margin: 0 auto;\n padding: 2.5rem 1.5rem 4rem;\n}\n.doc.mk-export--hide-scripts .mk-script {\n display: none;\n}\n@page {\n margin: 2cm;\n}\n@media print {\n .doc {\n max-width: none;\n margin: 0;\n padding: 0;\n }\n .doc .mk-card,\n .doc .mk-callout,\n .doc .mk-stat,\n .doc .mk-figure,\n .doc .mk-unknown,\n .doc table {\n break-inside: avoid;\n }\n /* A collapsed `details` prints as its summary alone, which loses the\n content the author wrote. On paper there is nothing to expand, so\n every disclosure prints open. */\n .doc details > *:not(summary) {\n display: block;\n }\n .doc .mk-script,\n .doc .mk-preview__run-marker {\n display: none;\n }\n}\n";
72
72
  /**
73
73
  * The static HTML engine, injected so this module imports no renderer.
74
- * A host passes `createHtmlExportEngine()` from `@markii/host/node`, which
74
+ * A host passes `createHtmlExportEngine()` from `@markii/host/html-engine`, which
75
75
  * wraps `@markii/html`; a test may pass a stub.
76
76
  */
77
77
  export interface HtmlExportEngine {
@@ -150,7 +150,7 @@ export interface HostPackSource {
150
150
  * `authorizedFolders` names.
151
151
  */
152
152
  bundledPacks?(): readonly DiscoveredPack[];
153
- /** Removes an installed pack folder. Required when `installRoot` is present. */
153
+ /** Removes an installed pack folder and everything under it. Required when `installRoot` is present: a replacing install clears the old folder through it, and removing a pack uses it. */
154
154
  removeFolder?(path: string): Promise<void>;
155
155
  }
156
156
  /** Every export format any host offers today. */
@@ -161,6 +161,14 @@ export interface HostExportCapabilities {
161
161
  resolveTarget(suggestedFileName: string, format: HostExportFormat): Promise<string | undefined>;
162
162
  /** Only present on a host declaring `'pdf'`. The one Electron-touching seam. */
163
163
  htmlToPdf?(html: string): Promise<Uint8Array>;
164
+ /**
165
+ * Names the zip archive of a linked-set export, or resolves `undefined`
166
+ * when the user cancels. Present only on a host that offers the
167
+ * linked-set export; a host can offer single-note export without it
168
+ * (the command line tool does), so it is a member of its own rather than
169
+ * a `HostExportFormat`.
170
+ */
171
+ resolveLinkedSetTarget?(suggestedFileName: string): Promise<string | undefined>;
164
172
  }
165
173
  /**
166
174
  * The wording nouns every shared behavior's message templates interpolate
@@ -194,10 +202,12 @@ export interface HostLabels {
194
202
  *.
195
203
  */
196
204
  export interface HostAdapter {
197
- readonly id: 'vscode' | 'obsidian' | 'cli' | (string & {});
205
+ /** A short name for the host, used in diagnostics wording. */
206
+ readonly id: string;
198
207
  readonly labels: HostLabels;
199
208
  readFile(path: string): Promise<Uint8Array>;
200
209
  exists(path: string): Promise<boolean>;
210
+ /** Writes `bytes` to `path` and creates any missing parent folder first. Exports and pack installs both write through it. */
201
211
  writeFile(path: string, bytes: Uint8Array): Promise<void>;
202
212
  /** A missing folder resolves to an empty list — never throws, matching the existing `PackDirectoryLister` contract. */
203
213
  listFolder(path: string): Promise<readonly HostDirEntry[]>;
@@ -208,7 +218,7 @@ export interface HostAdapter {
208
218
  * through with no adapter wrapper at all.
209
219
  */
210
220
  readonly memento: GrantMemento;
211
- /** One diagnostics line on this host's designated surface (docs/integration.md). */
221
+ /** One diagnostics line on this host's designated surface (docs/hosts.md). */
212
222
  diagnostics(line: string): void;
213
223
  now(): number;
214
224
  readonly isolate?: HostIsolate;
@@ -5,11 +5,12 @@
5
5
  * produces. Written once so the hosts cannot drift on what a cascade is
6
6
  * or what it says.
7
7
  *
8
- * Deliberately outside `createMarkiiHost`: choosing where the archive goes
9
- * and writing it are each host's own picker and file-system business, so
10
- * this module builds the bytes and words the outcome, and the host writes
11
- * and displays. The per-host nouns (which surface holds the details) come
12
- * from `HostLabels`, never from a literal here.
8
+ * `createMarkiiHost`'s `exportLinkedSet` drives it: the host's picker
9
+ * names the archive's path (`adapter.exports.resolveLinkedSetTarget`), the
10
+ * notes are read through `adapter.readFile`, the archive is written through
11
+ * `adapter.writeFile`, and the diagnostics lines go to `adapter.diagnostics`.
12
+ * The per-host nouns (which surface holds the details) come from
13
+ * `HostLabels`, never from a literal here.
13
14
  *
14
15
  * BOUNDS ARE FIXED. The walk always uses `DEFAULT_CASCADE_MAX_DEPTH` and
15
16
  * `DEFAULT_CASCADE_MAX_NOTES`; nothing here lets a caller override them.
@@ -19,7 +20,8 @@ import type { OnDiagnostic, UnavailableComponent } from '@markii/stdlib';
19
20
  import type { CascadeLinkResolver, CascadeNoteReader, CascadeTruncation } from '../export/cascade.js';
20
21
  import type { HtmlExportEngine, ExportBodyRenderer, ExportPackStylesheet, ExportRenderInfo, StaticExportReason } from '../export/note-export.js';
21
22
  import type { EmbeddedImageReport, ExportImageReader } from '../export/image-embed.js';
22
- import type { HostLabels } from './adapter.js';
23
+ import type { HostAdapter, HostLabels } from './adapter.js';
24
+ import type { ExportLinkedSetOutcome } from './create-host.js';
23
25
  /** One note the cascade exported, for the diagnostics surface. */
24
26
  export interface CascadeExportedNote {
25
27
  /** The note's own path, in the same form the reader and resolver use. */
@@ -37,7 +39,7 @@ export interface UnreadableCascadeNote {
37
39
  }
38
40
  /** What one cascade export attempt did, for both the message and the diagnostics lines. */
39
41
  export type CascadeExportOutcome = {
40
- readonly kind: 'written';
42
+ readonly kind: 'exported';
41
43
  /** The written archive's display path. */
42
44
  readonly path: string;
43
45
  readonly bytes: number;
@@ -109,7 +111,7 @@ export declare function buildCascadeArchive(request: CascadeArchiveRequest): Pro
109
111
  * failure says what failed and points at the same surface, never at a
110
112
  * stack trace.
111
113
  */
112
- export declare function cascadeResultMessage(outcome: CascadeExportOutcome, labels: HostLabels, where?: string): string;
114
+ export declare function cascadeResultMessage(outcome: ExportLinkedSetOutcome, labels: HostLabels, where?: string): string;
113
115
  /**
114
116
  * The lines for the host's diagnostics surface for one cascade export:
115
117
  * every note reached named with the file it became, every note skipped
@@ -118,3 +120,17 @@ export declare function cascadeResultMessage(outcome: CascadeExportOutcome, labe
118
120
  * deliberately omits.
119
121
  */
120
122
  export declare function cascadeDiagnosticLines(outcome: CascadeExportOutcome): string[];
123
+ /** What `exportLinkedSetViaAdapter` needs beyond the adapter: the static engine and the request's own seams. */
124
+ export interface LinkedSetExportInputs extends Omit<CascadeArchiveRequest, 'readNote'> {
125
+ /** The suggested archive file name handed to the host's picker. */
126
+ readonly suggestedFileName: string;
127
+ }
128
+ /**
129
+ * Exports a note and the notes it links to as one archive: resolves the
130
+ * target through the adapter's picker, builds the archive reading every
131
+ * note through `adapter.readFile`, writes it through `adapter.writeFile`,
132
+ * and sends the diagnostics lines to `adapter.diagnostics`. Resolves
133
+ * `undefined` when the host has no linked-set target or its picker was
134
+ * cancelled. Never throws.
135
+ */
136
+ export declare function exportLinkedSetViaAdapter(adapter: HostAdapter, inputs: LinkedSetExportInputs): Promise<CascadeExportOutcome | undefined>;
@@ -109,7 +109,7 @@ export async function buildCascadeArchive(request) {
109
109
  * stack trace.
110
110
  */
111
111
  export function cascadeResultMessage(outcome, labels, where) {
112
- if (outcome.kind === 'failed') {
112
+ if (outcome.kind !== 'exported') {
113
113
  return `Markii: could not export this cascade. Open ${labels.diagnosticsSurface} for details.`;
114
114
  }
115
115
  const name = fileNameOf(outcome.path);
@@ -149,3 +149,63 @@ export function cascadeDiagnosticLines(outcome) {
149
149
  }
150
150
  return lines;
151
151
  }
152
+ /**
153
+ * Exports a note and the notes it links to as one archive: resolves the
154
+ * target through the adapter's picker, builds the archive reading every
155
+ * note through `adapter.readFile`, writes it through `adapter.writeFile`,
156
+ * and sends the diagnostics lines to `adapter.diagnostics`. Resolves
157
+ * `undefined` when the host has no linked-set target or its picker was
158
+ * cancelled. Never throws.
159
+ */
160
+ export async function exportLinkedSetViaAdapter(adapter, inputs) {
161
+ const resolveTarget = adapter.exports?.resolveLinkedSetTarget;
162
+ if (resolveTarget === undefined)
163
+ return undefined;
164
+ const { suggestedFileName, ...archiveRequest } = inputs;
165
+ let path;
166
+ let outcome;
167
+ try {
168
+ path = await resolveTarget.call(adapter.exports, suggestedFileName);
169
+ if (path === undefined)
170
+ return undefined;
171
+ const target = path;
172
+ const archive = await buildCascadeArchive({
173
+ ...archiveRequest,
174
+ readNote: async (notePath) => {
175
+ try {
176
+ return new TextDecoder().decode(await adapter.readFile(notePath));
177
+ }
178
+ catch {
179
+ return undefined;
180
+ }
181
+ },
182
+ });
183
+ if (archive.kind === 'failed') {
184
+ outcome = { kind: 'failed', path: target, reason: archive.reason };
185
+ }
186
+ else {
187
+ await adapter.writeFile(target, archive.bytes);
188
+ outcome = {
189
+ kind: 'exported',
190
+ path: target,
191
+ bytes: archive.bytes.byteLength,
192
+ notes: archive.notes,
193
+ unreadable: archive.unreadable,
194
+ ...(archive.truncated !== undefined
195
+ ? { truncated: archive.truncated }
196
+ : {}),
197
+ };
198
+ }
199
+ }
200
+ catch (error) {
201
+ outcome = {
202
+ kind: 'failed',
203
+ ...(path !== undefined ? { path } : {}),
204
+ reason: cascadeReasonOf(error),
205
+ };
206
+ }
207
+ for (const line of cascadeDiagnosticLines(outcome)) {
208
+ adapter.diagnostics(line);
209
+ }
210
+ return outcome;
211
+ }
@@ -1,4 +1,3 @@
1
- import type { ArchiveExtractFs } from './pack-archive.js';
2
1
  import type { RunTrigger } from '@markii/runtime';
3
2
  import type { HostAdapter } from './adapter.js';
4
3
  /**
@@ -36,6 +35,8 @@ export type HostScenarioAction = {
36
35
  readonly type: 'exportHtml';
37
36
  } | {
38
37
  readonly type: 'exportAnsi';
38
+ } | {
39
+ readonly type: 'exportLinkedSet';
39
40
  } | {
40
41
  readonly type: 'installPack';
41
42
  /** A directory under the scenario's own folder (e.g. `"files/ana-1.0.0"`), zipped into `.mkp` bytes by this module — never a committed binary. */
@@ -99,7 +100,7 @@ export interface LoadedHostScenario {
99
100
  export declare function loadHostScenario(name: string, baseDir?: string): LoadedHostScenario;
100
101
  /** Zips a scenario's `files/<name>` directory into `.mkp` archive bytes on the fly — this is how the corpus ships pack fixtures as plain files instead of a committed binary. */
101
102
  export declare function zipArchiveDirectory(archiveDir: string): Uint8Array;
102
- export type HostScenarioCapability = 'isolate' | 'editor' | 'packs' | 'pack-trust' | 'export-format';
103
+ export type HostScenarioCapability = 'isolate' | 'editor' | 'packs' | 'pack-trust' | 'export-format' | 'linked-set-export';
103
104
  /** The one static map from an action's type to the capability it needs. `undefined` means every host supports it (there is nothing to decline). */
104
105
  export declare const ACTION_CAPABILITY: Readonly<Record<HostScenarioAction['type'], HostScenarioCapability | undefined>>;
105
106
  /** Whether `adapter` declares the capability `action` needs. Never consults the scenario or `expected.json` — only the adapter under test. */
@@ -135,8 +136,6 @@ export interface HostScenarioRunResult {
135
136
  */
136
137
  export declare function runHostScenario(adapter: HostAdapter, scenario: HostScenario, noteText: string, scenarioDir: string, options?: {
137
138
  readonly renderAnsi?: (text: string) => Promise<string>;
138
- /** Where an installed pack archive is extracted. Defaults to the Node file system, which is right for an adapter that reads and writes real files. An adapter over another store (memory, a virtual file system) passes the matching writer. */
139
- readonly archiveExtractFs?: ArchiveExtractFs;
140
139
  }): Promise<HostScenarioRunResult>;
141
140
  /** `expected[i]` compared against `actual[i]` for each index up to `expected`'s own length; a length mismatch is reported once rather than index-by-index noise. */
142
141
  export declare function containsInOrder(actual: readonly string[], expected: readonly string[]): string[];
@@ -37,13 +37,12 @@
37
37
  * Every OTHER action runs through the real, unmocked shared behavior code.
38
38
  */
39
39
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
40
- import { dirname } from 'node:path';
40
+ import { dirname, resolve } from 'node:path';
41
41
  import { fileURLToPath } from 'node:url';
42
42
  import { createHtmlExportEngine } from '../html-engine.js';
43
- import { createNodeArchiveExtractFs } from '../node-fs.js';
44
43
  import { usesPackTrust } from './pack-trust-behavior.js';
45
44
  import { join, relative, sep } from 'node:path';
46
- import { zipSync } from 'fflate';
45
+ import { unzipSync, zipSync } from 'fflate';
47
46
  import { conformanceDir } from '@markii/core/corpus';
48
47
  import { createMarkiiHost, } from './create-host.js';
49
48
  import { scriptsDisabledDiagnosticLine, scriptsDisabledNotice, } from './script-execution.js';
@@ -102,6 +101,7 @@ export const ACTION_CAPABILITY = {
102
101
  run: 'isolate',
103
102
  exportHtml: 'export-format',
104
103
  exportAnsi: 'export-format',
104
+ exportLinkedSet: 'linked-set-export',
105
105
  installPack: 'packs',
106
106
  loadPacks: 'packs',
107
107
  removePack: 'packs',
@@ -137,13 +137,13 @@ export function hostDeclaresCapability(adapter, action) {
137
137
  return adapter.isolate !== undefined;
138
138
  if (capability === 'editor')
139
139
  return adapter.editor !== undefined;
140
+ if (capability === 'linked-set-export') {
141
+ return adapter.exports?.resolveLinkedSetTarget !== undefined;
142
+ }
140
143
  if (capability === 'packs') {
141
144
  if (adapter.packs === undefined)
142
145
  return false;
143
- if (action.type === 'installPack') {
144
- return adapter.packs.installRoot !== undefined;
145
- }
146
- if (action.type === 'removePack') {
146
+ if (action.type === 'installPack' || action.type === 'removePack') {
147
147
  return (adapter.packs.installRoot !== undefined &&
148
148
  adapter.packs.removeFolder !== undefined);
149
149
  }
@@ -262,7 +262,6 @@ export async function runHostScenario(adapter, scenario, noteText, scenarioDir,
262
262
  // The runner is Node-only test tooling, so it supplies the real Node
263
263
  // implementations of the seams the host layer takes by injection.
264
264
  htmlEngine: createHtmlExportEngine(),
265
- archiveExtractFs: options.archiveExtractFs ?? createNodeArchiveExtractFs(),
266
265
  spawnRun: (spawnOptions) => Promise.resolve(simulatedRunResult(currentSimulation, spawnOptions)),
267
266
  ...(options.renderAnsi ? { renderAnsi: options.renderAnsi } : {}),
268
267
  });
@@ -334,6 +333,24 @@ async function runOneAction(host, adapter, action, ctx) {
334
333
  });
335
334
  return augmentExportOutcome(outcome, ctx.writtenFiles);
336
335
  }
336
+ case 'exportLinkedSet': {
337
+ const outcome = await host.exportLinkedSet({
338
+ rootPath: ctx.notePath,
339
+ // A markdown link to a note file that exists beside the linking
340
+ // note: the same rule for every host, so the archive's contents
341
+ // do not depend on which host runs the scenario.
342
+ resolveLink: (link, fromNotePath) => {
343
+ if (link.kind !== 'markdown')
344
+ return undefined;
345
+ const target = resolve(dirname(fromNotePath), link.path);
346
+ return target.endsWith('.md') && existsSync(target)
347
+ ? target
348
+ : undefined;
349
+ },
350
+ readValues: () => ({}),
351
+ });
352
+ return augmentLinkedSetOutcome(outcome, ctx.writtenFiles);
353
+ }
337
354
  case 'installPack': {
338
355
  const archiveBytes = zipArchiveDirectory(join(ctx.scenarioDir, action.archive));
339
356
  return host.installPack({ archiveBytes, archivePath: action.archive });
@@ -366,6 +383,13 @@ async function runOneAction(host, adapter, action, ctx) {
366
383
  return host.insertComponent({ name: action.name });
367
384
  }
368
385
  }
386
+ function augmentLinkedSetOutcome(outcome, writtenFiles) {
387
+ if (outcome.kind !== 'exported')
388
+ return outcome;
389
+ const bytes = writtenFiles.get(outcome.path);
390
+ const entries = bytes === undefined ? undefined : Object.keys(unzipSync(bytes)).sort();
391
+ return { ...outcome, noteCount: outcome.notes.length, entries };
392
+ }
369
393
  function augmentExportOutcome(outcome, writtenFiles) {
370
394
  if (outcome.kind !== 'exported')
371
395
  return outcome;
@@ -452,6 +476,25 @@ export function outcomeMismatches(actual, expected) {
452
476
  break;
453
477
  }
454
478
  case 'exported': {
479
+ if (expected['valueCount'] !== undefined &&
480
+ actualRecord['valueCount'] !== expected['valueCount']) {
481
+ mismatches.push(`valueCount: expected ${String(expected['valueCount'])}, got ${String(actualRecord['valueCount'])}`);
482
+ }
483
+ if (expected['renderEngine'] !== undefined) {
484
+ const render = actualRecord['render'];
485
+ if (render?.engine !== expected['renderEngine']) {
486
+ mismatches.push(`render engine: expected "${String(expected['renderEngine'])}", got "${String(render?.engine)}"`);
487
+ }
488
+ }
489
+ if (expected['noteCount'] !== undefined &&
490
+ actualRecord['noteCount'] !== expected['noteCount']) {
491
+ mismatches.push(`noteCount: expected ${String(expected['noteCount'])}, got ${String(actualRecord['noteCount'])}`);
492
+ }
493
+ if (expected['entries'] !== undefined &&
494
+ JSON.stringify(actualRecord['entries']) !==
495
+ JSON.stringify(expected['entries'])) {
496
+ mismatches.push(`archive entries: expected ${JSON.stringify(expected['entries'])}, got ${JSON.stringify(actualRecord['entries'])}`);
497
+ }
455
498
  const content = String(actualRecord['content'] ?? '');
456
499
  for (const needle of expected['contains'] ?? []) {
457
500
  if (!content.includes(needle)) {
@@ -466,6 +509,13 @@ export function outcomeMismatches(actual, expected) {
466
509
  break;
467
510
  }
468
511
  case 'installed': {
512
+ if (expected['installedDirName'] !== undefined) {
513
+ const dir = String(actualRecord['installedDir'] ?? '');
514
+ const name = dir.split(/[/\\]/).pop();
515
+ if (name !== expected['installedDirName']) {
516
+ mismatches.push(`installedDir: expected a folder named "${String(expected['installedDirName'])}", got "${dir}"`);
517
+ }
518
+ }
469
519
  if (expected['namespace'] !== undefined &&
470
520
  actualRecord['namespace'] !== expected['namespace']) {
471
521
  mismatches.push(`namespace: expected "${String(expected['namespace'])}", got "${String(actualRecord['namespace'])}"`);