@markii/host 0.17.0-next.4 → 0.17.0-next.6

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 (48) hide show
  1. package/README.md +28 -1
  2. package/dist/browser-worker.classic.js +43 -43
  3. package/dist/browser-worker.js +43 -43
  4. package/dist/conformance/host/17-export-linked-set/expected.json +17 -0
  5. package/dist/conformance/host/17-export-linked-set/files/second.mk.md +3 -0
  6. package/dist/conformance/host/17-export-linked-set/note.mk.md +4 -0
  7. package/dist/conformance/host/17-export-linked-set/scenario.json +6 -0
  8. package/dist/conformance/host/18-last-run-and-grants/expected.json +42 -0
  9. package/dist/conformance/host/18-last-run-and-grants/note.mk.md +5 -0
  10. package/dist/conformance/host/18-last-run-and-grants/scenario.json +27 -0
  11. package/dist/conformance/host/README.md +24 -14
  12. package/dist/conformance.d.ts +1 -1
  13. package/dist/conformance.js +1 -1
  14. package/dist/diagnostics/render-diagnostics.d.ts +1 -1
  15. package/dist/host/adapter.d.ts +28 -6
  16. package/dist/host/cascade-export-behavior.d.ts +26 -8
  17. package/dist/host/cascade-export-behavior.js +64 -1
  18. package/dist/host/conformance-runner.d.ts +11 -1
  19. package/dist/host/conformance-runner.js +80 -4
  20. package/dist/host/create-host.d.ts +250 -44
  21. package/dist/host/create-host.js +222 -96
  22. package/dist/host/export-behavior.d.ts +2 -0
  23. package/dist/host/export-behavior.js +10 -4
  24. package/dist/host/pack-archive.d.ts +7 -6
  25. package/dist/host/pack-archive.js +11 -7
  26. package/dist/host/pack-install.js +9 -7
  27. package/dist/host/pack-remove.d.ts +7 -18
  28. package/dist/host/pack-remove.js +2 -0
  29. package/dist/host/pack-trust-behavior.d.ts +13 -36
  30. package/dist/host/pack-trust-behavior.js +23 -2
  31. package/dist/host/run-behavior.d.ts +25 -34
  32. package/dist/host/run-behavior.js +31 -5
  33. package/dist/host/terminal-packs.d.ts +1 -1
  34. package/dist/index.d.ts +8 -10
  35. package/dist/index.js +1 -1
  36. package/dist/internal.d.ts +10 -5
  37. package/dist/internal.js +4 -3
  38. package/dist/node-worker.js +3 -1
  39. package/dist/node.d.ts +1 -2
  40. package/dist/node.js +1 -1
  41. package/dist/run/bundle-run.d.ts +1 -1
  42. package/dist/run/bundle-run.js +1 -1
  43. package/dist/run/node-isolate.d.ts +6 -2
  44. package/dist/run/node-isolate.js +2 -0
  45. package/dist/run/run-host.d.ts +2 -2
  46. package/dist/run/worker-entry-browser.d.ts +6 -1
  47. package/dist/run/worker-entry-browser.js +62 -5
  48. package/package.json +11 -11
@@ -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
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "outcomes": [
3
+ { "kind": "lastRun", "values": {} },
4
+ {
5
+ "kind": "ran",
6
+ "values": { "total": 3 },
7
+ "failureKinds": [],
8
+ "traceOk": true
9
+ },
10
+ {
11
+ "kind": "lastRun",
12
+ "values": { "total": 3 },
13
+ "valueStatuses": { "total": "stale" },
14
+ "traceOk": true
15
+ },
16
+ {
17
+ "kind": "ran",
18
+ "values": { "total": 3 },
19
+ "failureKinds": [],
20
+ "traceOk": true
21
+ },
22
+ { "kind": "forgotten" },
23
+ {
24
+ "kind": "ran",
25
+ "values": { "total": 3 },
26
+ "failureKinds": [],
27
+ "traceOk": true
28
+ }
29
+ ],
30
+ "diagnostics": [
31
+ "manual run: net declaration mismatch",
32
+ "uses net access to api.example.com",
33
+ "manual run: net declaration mismatch",
34
+ "uses net access to api.example.com",
35
+ "manual run: net declaration mismatch",
36
+ "uses net access to api.example.com"
37
+ ],
38
+ "prompts": [
39
+ "This note's scripts can send data to api.example.com. Allow?",
40
+ "This note's scripts can send data to api.example.com. Allow?"
41
+ ]
42
+ }
@@ -0,0 +1,5 @@
1
+ # Last run and grants
2
+
3
+ ```lua {name=total}
4
+ return net.fetch_json("https://api.example.com/orders")
5
+ ```
@@ -0,0 +1,27 @@
1
+ {
2
+ "id": "last-run-and-grants",
3
+ "invariant": "a run's values and trace can be read back with no storage key, and clearing the stored grants makes the next manual run prompt again",
4
+ "clock": 1700000000000,
5
+ "actions": [
6
+ { "type": "lastRun" },
7
+ {
8
+ "type": "run",
9
+ "trigger": "manual",
10
+ "answers": ["allow"],
11
+ "simulate": { "name": "total", "value": 3, "requiresHost": "api.example.com" }
12
+ },
13
+ { "type": "lastRun" },
14
+ {
15
+ "type": "run",
16
+ "trigger": "manual",
17
+ "simulate": { "name": "total", "value": 3, "requiresHost": "api.example.com" }
18
+ },
19
+ { "type": "forgetGrants" },
20
+ {
21
+ "type": "run",
22
+ "trigger": "manual",
23
+ "answers": ["allow"],
24
+ "simulate": { "name": "total", "value": 3, "requiresHost": "api.example.com" }
25
+ }
26
+ ]
27
+ }
@@ -47,8 +47,11 @@ needs, is the runner's own `ACTION_CAPABILITY` map:
47
47
  |---|---|
48
48
  | `open` | (always supported) |
49
49
  | `run` | `isolate` |
50
+ | `lastRun` | (always supported) |
51
+ | `forgetGrants` | (always supported) |
50
52
  | `exportHtml` | `export-format` (`html`) |
51
53
  | `exportAnsi` | `export-format` (`ansi`) |
54
+ | `exportLinkedSet` | `linked-set-export` (a `resolveLinkedSetTarget`) |
52
55
  | `installPack` | `packs` (and an `installRoot`) |
53
56
  | `loadPacks` | `packs` |
54
57
  | `removePack` | `packs` (and an `installRoot` plus `removeFolder`) |
@@ -97,12 +100,12 @@ would have produced:
97
100
  and persists exactly what it always does. Only the isolate that would
98
101
  execute the script is faked.
99
102
  - `requiresManualTier`: the run reports `tier-blocked` under any trigger
100
- but `'manual'`, regardless of grants — the same enforcement the real
103
+ but `'manual'`, regardless of grants, the same enforcement the real
101
104
  sandbox applies to an effectful call under a read-only trigger.
102
105
 
103
106
  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
107
+ HOST-LEVEL plumbing, grant prompts, wording, capability declarations,
108
+ trigger forwarding, is identical across hosts. Real sandbox enforcement
106
109
  (the tier gate, the net allowlist, the marshal boundary) is
107
110
  `conformance/executor/`'s job and is not duplicated here.
108
111
 
@@ -136,15 +139,21 @@ coarse, matching each `kind`:
136
139
  (`{name: value}`); `failureKinds` compares the closed `FailureKind`
137
140
  taxonomy only, never a raw message or a timing; an optional `traceOk`
138
141
  compares the recorded run trace's `ok` flag.
142
+ - `lastRun`: `values` compares the stored values shown on open, by name,
143
+ like `ran`. `valueStatuses` compares each value's status by name, and
144
+ `traceOk` compares the recorded trace's `ok` flag.
139
145
  - `exported`: `contains`/`excludes` are substrings checked against the
140
- written file's content, never the whole document — the render corpora
146
+ written file's content, never the whole document, the render corpora
141
147
  already pin bytes. `valueCount` is how many last-run values were baked
142
148
  in, and `renderEngine` (`static` or `react`) is which engine drew the body.
149
+ A linked-set export is an `exported` outcome too: `noteCount` is how many
150
+ notes the archive holds, and `entries` is its sorted file names.
143
151
  - `installed`: `namespace` and `replaced`, and `installedDirName`, the name
144
152
  of the folder the pack was unzipped into.
145
153
  - `declined`: `step` (`'consent'`/`'replace'`, or absent for the
146
154
  scripts-disabled synthetic outcome) and `reasonContains`, a substring of
147
- the user-facing reason.
155
+ the user-facing reason. A user's "no" to a prompt and a cancelled picker
156
+ are both `declined`.
148
157
  - `listed`: `enabled` and `notEnabled`, namespace lists, order-independent.
149
158
  - `enabled`, `disabled`, `placed`, `removed`: `namespace`.
150
159
  - `loaded`: `namespaces`, order-independent.
@@ -156,12 +165,12 @@ coarse, matching each `kind`:
156
165
  accepted container item.
157
166
  - `hover`: `contains`, a substring of the hover text. Optional `start` and
158
167
  `end` compare the hovered name's columns.
159
- - every other `kind` (`opened`, `none`, `not-found`, `failed`, `rejected`,
160
- `reserved`, `cancelled`, `inserted`): `kind` equality is the whole check.
168
+ - every other `kind` (`opened`, `none`, `not-found`, `failed`, `invalid`,
169
+ `reserved`, `forgotten`, `inserted`): `kind` equality is the whole check.
161
170
 
162
171
  An action whose capability the adapter under test does not declare is
163
172
  never diffed against `expected.json` at all (see "Capability handling"
164
- below) — `expected.json` always describes the FULLY CAPABLE host's
173
+ below), `expected.json` always describes the FULLY CAPABLE host's
165
174
  correct behavior, even for a scenario where one real host today has
166
175
  nothing to compare it to.
167
176
 
@@ -173,11 +182,11 @@ difference) without the corpus having to spell out three wordings for
173
182
  one behavior. Diagnostics lines written by a capability decline itself
174
183
  (`declined (<capability> unsupported on <host>): …`) are filtered out of
175
184
  the actual list before this comparison, since that line exists only
176
- because the capability was ABSENT — it is not part of what
185
+ because the capability was ABSENT, it is not part of what
177
186
  `expected.json` describes.
178
187
 
179
188
  `prompts` is load-bearing: it proves all three hosts ask the same
180
- questions, worded the same way, in the same order — the single most
189
+ questions, worded the same way, in the same order, the single most
181
190
  security-relevant parity property in this batch.
182
191
 
183
192
  ## Capability handling (the crux)
@@ -197,7 +206,7 @@ run the whole corpus honestly: its result records say `unsupported`
197
206
  where it genuinely offers nothing, which is the truth and is visible,
198
207
  rather than a skipped test or a faked empty answer.
199
208
 
200
- ## The sixteen scenarios
209
+ ## The scenarios
201
210
 
202
211
  | id | what it proves |
203
212
  |---|---|
@@ -217,7 +226,8 @@ rather than a skipped test or a faked empty answer.
217
226
  | `14-remove-pack` | removing an installed pack deletes it; removing it again reports it is not installed |
218
227
  | `15-load-packs-engine-views` | loading packs reports every installed component and, as `unavailable`, the ones this host's engine has no view for |
219
228
  | `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` |
229
+ | `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` |
230
+ | `18-last-run-and-grants` | a run's values and trace read back through `lastRun` with no storage key, and clearing the grants makes the next manual run prompt again |
220
231
 
221
- See `tmp/W11-phase3.md` for which capabilities each of the three real
222
- adapters currently declares, and which scenarios are consequently
223
- `unsupported` on every host today (a reported gap, not a corpus defect).
232
+ The capabilities an adapter declares decide which scenarios report
233
+ `unsupported` on that host. That is a reported gap and not a corpus defect.
@@ -1,2 +1,2 @@
1
- export { ACTION_CAPABILITY, assertHostScenario, containsInOrder, createAnswerQueue, hostConformanceDir, hostDeclaresCapability, listHostScenarioNames, loadHostScenario, outcomeMismatches, runHostScenario, zipArchiveDirectory, } from './host/conformance-runner.js';
1
+ export { assertHostScenario, createAnswerQueue, hostConformanceDir, listHostScenarioNames, loadHostScenario, runHostScenario, } from './host/conformance-runner.js';
2
2
  export type { HostScenario, HostScenarioAction, HostScenarioCapability, HostScenarioExpected, HostScenarioRunResult, LoadedHostScenario, RunSimulation, } from './host/conformance-runner.js';
@@ -2,4 +2,4 @@
2
2
  // the corpus from disk and drives a real adapter). A host's adapter test
3
3
  // imports this to run `conformance/host/` against its own `HostAdapter`.
4
4
  // The corpus ships inside the package at `dist/conformance/host/`.
5
- export { ACTION_CAPABILITY, assertHostScenario, containsInOrder, createAnswerQueue, hostConformanceDir, hostDeclaresCapability, listHostScenarioNames, loadHostScenario, outcomeMismatches, runHostScenario, zipArchiveDirectory, } from './host/conformance-runner.js';
5
+ export { assertHostScenario, createAnswerQueue, hostConformanceDir, listHostScenarioNames, loadHostScenario, runHostScenario, } from './host/conformance-runner.js';
@@ -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
@@ -54,9 +54,9 @@ export type HostIsolate = {
54
54
  */
55
55
  workerPath(): string;
56
56
  /**
57
- * Creates the worker-thread isolate: `workerThreadIsolate` from
58
- * `@markii/host/node`. Injected so the shared run path imports
59
- * nothing from `node:*`.
57
+ * Creates the worker-thread isolate. `createNodeHostIsolate()` from
58
+ * `@markii/host/node` supplies it. Injected so the shared run path
59
+ * imports nothing from `node:*`.
60
60
  */
61
61
  readonly spawnIsolate: IsolateSpawner;
62
62
  } | {
@@ -155,12 +155,34 @@ export interface HostPackSource {
155
155
  }
156
156
  /** Every export format any host offers today. */
157
157
  export type HostExportFormat = 'html' | 'pdf' | 'ansi' | 'md-plain';
158
+ /**
159
+ * What a host declares to offer exports. A request can carry its own
160
+ * `targetPath`, so a host with no picker (the command line tool, or a call
161
+ * site that already knows where the file goes) leaves the resolvers out.
162
+ *
163
+ * A single-note export needs `exports` declared with the format in
164
+ * `formats`, and a destination: either a `resolveTarget` or the request's
165
+ * `targetPath`. Without a destination it answers `unsupported`.
166
+ *
167
+ * The linked-set export is offered when `exports` is declared and the host
168
+ * has a `resolveLinkedSetTarget` or the request carries a `targetPath`.
169
+ * Without either it answers `unsupported`. A host that declares `exports`
170
+ * with neither resolver, such as the command line tool, therefore offers
171
+ * the linked-set export only to a caller that names the path.
172
+ */
158
173
  export interface HostExportCapabilities {
159
174
  readonly formats: ReadonlySet<HostExportFormat>;
160
- /** GUI hosts show a picker; the CLI returns the path it was given. */
161
- resolveTarget(suggestedFileName: string, format: HostExportFormat): Promise<string | undefined>;
175
+ /** GUI hosts show a picker; the CLI returns the path it was given. Resolves `undefined` when the user cancels. Absent for a host whose callers always pass `targetPath`. */
176
+ resolveTarget?(suggestedFileName: string, format: HostExportFormat): Promise<string | undefined>;
162
177
  /** Only present on a host declaring `'pdf'`. The one Electron-touching seam. */
163
178
  htmlToPdf?(html: string): Promise<Uint8Array>;
179
+ /**
180
+ * Names the zip archive of a linked-set export, or resolves `undefined`
181
+ * when the user cancels. A host can offer single-note export without it
182
+ * (the command line tool does), so it is a member of its own rather than
183
+ * a `HostExportFormat`.
184
+ */
185
+ resolveLinkedSetTarget?(suggestedFileName: string): Promise<string | undefined>;
164
186
  }
165
187
  /**
166
188
  * The wording nouns every shared behavior's message templates interpolate
@@ -210,7 +232,7 @@ export interface HostAdapter {
210
232
  * through with no adapter wrapper at all.
211
233
  */
212
234
  readonly memento: GrantMemento;
213
- /** One diagnostics line on this host's designated surface (docs/integration.md). */
235
+ /** One diagnostics line on this host's designated surface (docs/hosts.md). */
214
236
  diagnostics(line: string): void;
215
237
  now(): number;
216
238
  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,19 @@ 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
+ /** Where to write the archive. When present the adapter's `resolveLinkedSetTarget` is not asked. */
128
+ readonly targetPath?: string;
129
+ }
130
+ /**
131
+ * Exports a note and the notes it links to as one archive: resolves the
132
+ * target through the adapter's picker, builds the archive reading every
133
+ * note through `adapter.readFile`, writes it through `adapter.writeFile`,
134
+ * and sends the diagnostics lines to `adapter.diagnostics`. Resolves
135
+ * `undefined` when the host has no linked-set target or its picker was
136
+ * cancelled. A `targetPath` skips the picker. Never throws.
137
+ */
138
+ 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,66 @@ 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. A `targetPath` skips the picker. Never throws.
159
+ */
160
+ export async function exportLinkedSetViaAdapter(adapter, inputs) {
161
+ const resolveTarget = adapter.exports?.resolveLinkedSetTarget;
162
+ const { suggestedFileName, targetPath, ...archiveRequest } = inputs;
163
+ if (targetPath === undefined && resolveTarget === undefined) {
164
+ return undefined;
165
+ }
166
+ let path;
167
+ let outcome;
168
+ try {
169
+ path =
170
+ targetPath ??
171
+ (await resolveTarget?.call(adapter.exports, suggestedFileName));
172
+ if (path === undefined)
173
+ return undefined;
174
+ const target = path;
175
+ const archive = await buildCascadeArchive({
176
+ ...archiveRequest,
177
+ readNote: async (notePath) => {
178
+ try {
179
+ return new TextDecoder().decode(await adapter.readFile(notePath));
180
+ }
181
+ catch {
182
+ return undefined;
183
+ }
184
+ },
185
+ });
186
+ if (archive.kind === 'failed') {
187
+ outcome = { kind: 'failed', path: target, reason: archive.reason };
188
+ }
189
+ else {
190
+ await adapter.writeFile(target, archive.bytes);
191
+ outcome = {
192
+ kind: 'exported',
193
+ path: target,
194
+ bytes: archive.bytes.byteLength,
195
+ notes: archive.notes,
196
+ unreadable: archive.unreadable,
197
+ ...(archive.truncated !== undefined
198
+ ? { truncated: archive.truncated }
199
+ : {}),
200
+ };
201
+ }
202
+ }
203
+ catch (error) {
204
+ outcome = {
205
+ kind: 'failed',
206
+ ...(path !== undefined ? { path } : {}),
207
+ reason: cascadeReasonOf(error),
208
+ };
209
+ }
210
+ for (const line of cascadeDiagnosticLines(outcome)) {
211
+ adapter.diagnostics(line);
212
+ }
213
+ return outcome;
214
+ }
@@ -31,10 +31,20 @@ export type HostScenarioAction = {
31
31
  /** When `true`, this action never reaches `host.run` at all — see this file's top comment on the scripts-disabled scenario. */
32
32
  readonly scriptsDisabled?: boolean;
33
33
  readonly simulate?: RunSimulation;
34
+ }
35
+ /** Reads what the note's last run left: the values to show on open and the trace. */
36
+ | {
37
+ readonly type: 'lastRun';
38
+ }
39
+ /** Clears the note's stored script grants. */
40
+ | {
41
+ readonly type: 'forgetGrants';
34
42
  } | {
35
43
  readonly type: 'exportHtml';
36
44
  } | {
37
45
  readonly type: 'exportAnsi';
46
+ } | {
47
+ readonly type: 'exportLinkedSet';
38
48
  } | {
39
49
  readonly type: 'installPack';
40
50
  /** 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. */
@@ -98,7 +108,7 @@ export interface LoadedHostScenario {
98
108
  export declare function loadHostScenario(name: string, baseDir?: string): LoadedHostScenario;
99
109
  /** 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. */
100
110
  export declare function zipArchiveDirectory(archiveDir: string): Uint8Array;
101
- export type HostScenarioCapability = 'isolate' | 'editor' | 'packs' | 'pack-trust' | 'export-format';
111
+ export type HostScenarioCapability = 'isolate' | 'editor' | 'packs' | 'pack-trust' | 'export-format' | 'linked-set-export';
102
112
  /** 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). */
103
113
  export declare const ACTION_CAPABILITY: Readonly<Record<HostScenarioAction['type'], HostScenarioCapability | undefined>>;
104
114
  /** Whether `adapter` declares the capability `action` needs. Never consults the scenario or `expected.json` — only the adapter under test. */
@@ -37,14 +37,14 @@
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
43
  import { usesPackTrust } from './pack-trust-behavior.js';
44
44
  import { join, relative, sep } from 'node:path';
45
- import { zipSync } from 'fflate';
45
+ import { unzipSync, zipSync } from 'fflate';
46
46
  import { conformanceDir } from '@markii/core/corpus';
47
- import { createMarkiiHost, } from './create-host.js';
47
+ import { createMarkiiHostWithSeams, } from './create-host.js';
48
48
  import { scriptsDisabledDiagnosticLine, scriptsDisabledNotice, } from './script-execution.js';
49
49
  // --- corpus location ----------------------------------------------------
50
50
  /**
@@ -99,8 +99,11 @@ export function zipArchiveDirectory(archiveDir) {
99
99
  export const ACTION_CAPABILITY = {
100
100
  open: undefined,
101
101
  run: 'isolate',
102
+ lastRun: undefined,
103
+ forgetGrants: undefined,
102
104
  exportHtml: 'export-format',
103
105
  exportAnsi: 'export-format',
106
+ exportLinkedSet: 'linked-set-export',
104
107
  installPack: 'packs',
105
108
  loadPacks: 'packs',
106
109
  removePack: 'packs',
@@ -136,6 +139,9 @@ export function hostDeclaresCapability(adapter, action) {
136
139
  return adapter.isolate !== undefined;
137
140
  if (capability === 'editor')
138
141
  return adapter.editor !== undefined;
142
+ if (capability === 'linked-set-export') {
143
+ return adapter.exports?.resolveLinkedSetTarget !== undefined;
144
+ }
139
145
  if (capability === 'packs') {
140
146
  if (adapter.packs === undefined)
141
147
  return false;
@@ -254,7 +260,7 @@ export async function runHostScenario(adapter, scenario, noteText, scenarioDir,
254
260
  },
255
261
  };
256
262
  let currentSimulation;
257
- const host = createMarkiiHost(recording, {
263
+ const host = createMarkiiHostWithSeams(recording, {
258
264
  // The runner is Node-only test tooling, so it supplies the real Node
259
265
  // implementations of the seams the host layer takes by injection.
260
266
  htmlEngine: createHtmlExportEngine(),
@@ -320,6 +326,18 @@ async function runOneAction(host, adapter, action, ctx) {
320
326
  trigger: action.trigger,
321
327
  });
322
328
  }
329
+ case 'lastRun': {
330
+ const last = host.lastRun(ctx.notePath);
331
+ return {
332
+ kind: 'lastRun',
333
+ values: last.values,
334
+ persistedValues: last.persistedValues,
335
+ ...(last.trace !== undefined ? { trace: last.trace } : {}),
336
+ };
337
+ }
338
+ case 'forgetGrants':
339
+ await host.forgetGrants(ctx.notePath);
340
+ return { kind: 'forgotten' };
323
341
  case 'exportHtml':
324
342
  case 'exportAnsi': {
325
343
  const outcome = await host.exportNote({
@@ -329,6 +347,24 @@ async function runOneAction(host, adapter, action, ctx) {
329
347
  });
330
348
  return augmentExportOutcome(outcome, ctx.writtenFiles);
331
349
  }
350
+ case 'exportLinkedSet': {
351
+ const outcome = await host.exportLinkedSet({
352
+ rootPath: ctx.notePath,
353
+ // A markdown link to a note file that exists beside the linking
354
+ // note: the same rule for every host, so the archive's contents
355
+ // do not depend on which host runs the scenario.
356
+ resolveLink: (link, fromNotePath) => {
357
+ if (link.kind !== 'markdown')
358
+ return undefined;
359
+ const target = resolve(dirname(fromNotePath), link.path);
360
+ return target.endsWith('.md') && existsSync(target)
361
+ ? target
362
+ : undefined;
363
+ },
364
+ readValues: () => ({}),
365
+ });
366
+ return augmentLinkedSetOutcome(outcome, ctx.writtenFiles);
367
+ }
332
368
  case 'installPack': {
333
369
  const archiveBytes = zipArchiveDirectory(join(ctx.scenarioDir, action.archive));
334
370
  return host.installPack({ archiveBytes, archivePath: action.archive });
@@ -361,6 +397,13 @@ async function runOneAction(host, adapter, action, ctx) {
361
397
  return host.insertComponent({ name: action.name });
362
398
  }
363
399
  }
400
+ function augmentLinkedSetOutcome(outcome, writtenFiles) {
401
+ if (outcome.kind !== 'exported')
402
+ return outcome;
403
+ const bytes = writtenFiles.get(outcome.path);
404
+ const entries = bytes === undefined ? undefined : Object.keys(unzipSync(bytes)).sort();
405
+ return { ...outcome, noteCount: outcome.notes.length, entries };
406
+ }
364
407
  function augmentExportOutcome(outcome, writtenFiles) {
365
408
  if (outcome.kind !== 'exported')
366
409
  return outcome;
@@ -446,6 +489,30 @@ export function outcomeMismatches(actual, expected) {
446
489
  }
447
490
  break;
448
491
  }
492
+ case 'lastRun': {
493
+ const wantValues = expected['values'] ?? {};
494
+ const gotValues = landedValues(actualRecord['values']);
495
+ if (JSON.stringify(gotValues) !== JSON.stringify(wantValues)) {
496
+ mismatches.push(`values: expected ${JSON.stringify(wantValues)}, got ${JSON.stringify(gotValues)}`);
497
+ }
498
+ if (expected['valueStatuses'] !== undefined) {
499
+ const statuses = {};
500
+ const raw = (actualRecord['values'] ?? {});
501
+ for (const [name, entry] of Object.entries(raw)) {
502
+ statuses[name] = entry.status;
503
+ }
504
+ if (JSON.stringify(statuses) !== JSON.stringify(expected['valueStatuses'])) {
505
+ mismatches.push(`value statuses: expected ${JSON.stringify(expected['valueStatuses'])}, got ${JSON.stringify(statuses)}`);
506
+ }
507
+ }
508
+ if (expected['traceOk'] !== undefined) {
509
+ const trace = actualRecord['trace'];
510
+ if (trace?.ok !== expected['traceOk']) {
511
+ mismatches.push(`trace.ok: expected ${String(expected['traceOk'])}, got ${String(trace?.ok)}`);
512
+ }
513
+ }
514
+ break;
515
+ }
449
516
  case 'exported': {
450
517
  if (expected['valueCount'] !== undefined &&
451
518
  actualRecord['valueCount'] !== expected['valueCount']) {
@@ -457,6 +524,15 @@ export function outcomeMismatches(actual, expected) {
457
524
  mismatches.push(`render engine: expected "${String(expected['renderEngine'])}", got "${String(render?.engine)}"`);
458
525
  }
459
526
  }
527
+ if (expected['noteCount'] !== undefined &&
528
+ actualRecord['noteCount'] !== expected['noteCount']) {
529
+ mismatches.push(`noteCount: expected ${String(expected['noteCount'])}, got ${String(actualRecord['noteCount'])}`);
530
+ }
531
+ if (expected['entries'] !== undefined &&
532
+ JSON.stringify(actualRecord['entries']) !==
533
+ JSON.stringify(expected['entries'])) {
534
+ mismatches.push(`archive entries: expected ${JSON.stringify(expected['entries'])}, got ${JSON.stringify(actualRecord['entries'])}`);
535
+ }
460
536
  const content = String(actualRecord['content'] ?? '');
461
537
  for (const needle of expected['contains'] ?? []) {
462
538
  if (!content.includes(needle)) {