@playola-radio/slipstream 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +132 -0
  3. package/SKILL.md +63 -0
  4. package/THIRD-PARTY-NOTICES.md +36 -0
  5. package/contracts/interface/v2/schema.json +480 -0
  6. package/dist/answer-forwarder.js +58 -0
  7. package/dist/attribution-engine.js +164 -0
  8. package/dist/attribution-producer.js +153 -0
  9. package/dist/attribution-scoring.js +86 -0
  10. package/dist/attribution.js +120 -0
  11. package/dist/blob-source.js +100 -0
  12. package/dist/boundary-registry.js +47 -0
  13. package/dist/cas.js +99 -0
  14. package/dist/change-view.js +201 -0
  15. package/dist/claude-root-transcript.js +110 -0
  16. package/dist/cli.js +629 -0
  17. package/dist/clip-blob-reader.js +104 -0
  18. package/dist/clip-function-parser.js +83 -0
  19. package/dist/clip-language.js +15 -0
  20. package/dist/clip-projection-service.js +240 -0
  21. package/dist/clip-projection-worker.js +18 -0
  22. package/dist/clip-projection.js +585 -0
  23. package/dist/clip-worker-pool.js +118 -0
  24. package/dist/config.js +154 -0
  25. package/dist/control-client.js +98 -0
  26. package/dist/control-protocol.js +72 -0
  27. package/dist/daemon-location.js +33 -0
  28. package/dist/daemon.js +951 -0
  29. package/dist/display-fold.js +332 -0
  30. package/dist/engine.js +112 -0
  31. package/dist/entrypoint.js +17 -0
  32. package/dist/event.js +79 -0
  33. package/dist/evidence-ingest.js +60 -0
  34. package/dist/harness-context/claude.js +42 -0
  35. package/dist/harness-context/codex.js +49 -0
  36. package/dist/harness-context.js +49 -0
  37. package/dist/health.js +65 -0
  38. package/dist/http-reader.js +655 -0
  39. package/dist/http-security.js +50 -0
  40. package/dist/interface-projection.js +243 -0
  41. package/dist/interface-range-resolver.js +230 -0
  42. package/dist/interface-service.js +509 -0
  43. package/dist/interface-ts-pool.js +111 -0
  44. package/dist/interface-ts-worker.js +35 -0
  45. package/dist/interface-v2-comparison.js +135 -0
  46. package/dist/interface-v2-core.js +123 -0
  47. package/dist/interface-v2-typescript.js +448 -0
  48. package/dist/lock.js +197 -0
  49. package/dist/log-reader.js +148 -0
  50. package/dist/log.js +59 -0
  51. package/dist/maintenance.js +255 -0
  52. package/dist/mcp-forwarder.js +161 -0
  53. package/dist/mcp-protocol.js +92 -0
  54. package/dist/platform.js +21 -0
  55. package/dist/projection-admission.js +296 -0
  56. package/dist/projection-trace.js +17 -0
  57. package/dist/public-events.js +27 -0
  58. package/dist/question-hook.js +110 -0
  59. package/dist/questions.js +167 -0
  60. package/dist/reader-runtime.js +38 -0
  61. package/dist/reader.js +93 -0
  62. package/dist/recovery.js +303 -0
  63. package/dist/schema.js +108 -0
  64. package/dist/session.js +866 -0
  65. package/dist/sibling-module.js +10 -0
  66. package/dist/snapshot.js +15 -0
  67. package/dist/storage.js +111 -0
  68. package/dist/store-reader.js +185 -0
  69. package/dist/swift-grammar.js +268 -0
  70. package/dist/swift-interface-extract.js +344 -0
  71. package/dist/swift-interface.js +62 -0
  72. package/dist/swift-parse-host.js +174 -0
  73. package/dist/swift-parse-worker.js +34 -0
  74. package/dist/swift-parse.js +134 -0
  75. package/dist/swift-spans.js +62 -0
  76. package/dist/task-forwarder.js +152 -0
  77. package/dist/transcript/claude.js +199 -0
  78. package/dist/transcript/codex.js +132 -0
  79. package/dist/transcript/discovery.js +365 -0
  80. package/dist/transcript/file-reader.js +128 -0
  81. package/dist/transcript/fs-io.js +287 -0
  82. package/dist/transcript/runner.js +62 -0
  83. package/dist/transcript/steppers.js +30 -0
  84. package/dist/transcript/types.js +30 -0
  85. package/dist/transcript/watcher.js +133 -0
  86. package/dist/tui.js +168 -0
  87. package/package.json +60 -0
  88. package/schemas/projections/clip.v1.json +195 -0
  89. package/schemas/projections/clip.v2.json +212 -0
  90. package/schemas/projections/clip.v3.json +212 -0
  91. package/schemas/slipstream.capture.baseline.completed.v1.json +62 -0
  92. package/schemas/slipstream.capture.gap.v1.json +112 -0
  93. package/schemas/slipstream.change.attribution.v1.json +115 -0
  94. package/schemas/slipstream.enrichment.configured.v1.json +77 -0
  95. package/schemas/slipstream.enrichment.coverage.v1.json +95 -0
  96. package/schemas/slipstream.file.baselined.v1.json +122 -0
  97. package/schemas/slipstream.file.changed.v1.json +179 -0
  98. package/schemas/slipstream.harness.evidence.v1.json +145 -0
  99. package/schemas/slipstream.question.answered.v1.json +29 -0
  100. package/schemas/slipstream.question.dispatch_attempted.v1.json +28 -0
  101. package/schemas/slipstream.question.queued.v1.json +55 -0
  102. package/schemas/slipstream.session.resumed.v1.json +70 -0
  103. package/schemas/slipstream.session.started.v1.json +69 -0
  104. package/schemas/slipstream.task.started.v1.json +72 -0
@@ -0,0 +1,10 @@
1
+ /**
2
+ * URL of a sibling module (given without extension) that a worker or child
3
+ * process will load. Source runs as `.ts`; the published package ships compiled
4
+ * `.js`, and `tsc` does not rewrite extensions inside `new URL(...)`, so the
5
+ * extension is taken from the calling module's own URL.
6
+ */
7
+ export function siblingModuleUrl(name, callerUrl) {
8
+ const ext = callerUrl.endsWith('.ts') ? '.ts' : '.js';
9
+ return new URL(`./${name}${ext}`, callerUrl);
10
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * True when two *consecutive* observations carry the same information and the
3
+ * later one should be suppressed. This is deliberately not global content
4
+ * dedup: an A -> B -> A cycle produces two real transitions because each
5
+ * comparison is only against the immediately preceding committed state.
6
+ */
7
+ export function snapshotsEqual(a, b) {
8
+ if (a.kind !== b.kind)
9
+ return false;
10
+ if (a.kind === 'content' && b.kind === 'content')
11
+ return a.sha256 === b.sha256;
12
+ if (a.kind === 'unavailable' && b.kind === 'unavailable')
13
+ return a.reason === b.reason;
14
+ return true; // absent === absent
15
+ }
@@ -0,0 +1,111 @@
1
+ import { access, lstat, mkdir, open } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ /** Owner-only permissions for everything Slipstream writes (captured source bytes). */
4
+ export const DIR_MODE = 0o700;
5
+ export const FILE_MODE = 0o600;
6
+ /**
7
+ * A storage-layer failure (ENOSPC, quota, permission, I/O). Distinct from a
8
+ * source-file read problem: a full or broken *store* must never be laundered
9
+ * into an `unavailable` snapshot — that would fabricate a coverage claim. The
10
+ * `code` mirrors the OS errno so callers can single out ENOSPC/EDQUOT.
11
+ */
12
+ export class StorageError extends Error {
13
+ code;
14
+ operation;
15
+ constructor(operation, cause) {
16
+ const code = cause?.code;
17
+ super(`storage ${operation} failed${code ? ` (${code})` : ''}`, { cause });
18
+ this.name = 'StorageError';
19
+ this.code = code;
20
+ this.operation = operation;
21
+ }
22
+ }
23
+ /** fsync a directory so a newly created/renamed entry within it is durable. */
24
+ export async function fsyncDir(dir) {
25
+ let handle;
26
+ try {
27
+ handle = await open(dir, 'r');
28
+ await handle.sync();
29
+ }
30
+ catch (err) {
31
+ throw new StorageError(`fsync-dir(${dir})`, err);
32
+ }
33
+ finally {
34
+ await handle?.close();
35
+ }
36
+ }
37
+ /**
38
+ * Write the whole buffer, looping on short writes. A single `handle.write` may
39
+ * commit fewer bytes than requested under storage pressure; a truncated line
40
+ * would corrupt every record after it, so the loop is not optional.
41
+ */
42
+ export async function writeAll(handle, buf) {
43
+ let offset = 0;
44
+ while (offset < buf.length) {
45
+ const { bytesWritten } = await handle.write(buf, offset, buf.length - offset);
46
+ if (bytesWritten <= 0)
47
+ throw new Error('write made no progress');
48
+ offset += bytesWritten;
49
+ }
50
+ }
51
+ /**
52
+ * Create `dir` and any missing ancestors 0700, fsyncing each newly-created
53
+ * directory's parent so the new entry is durable. `mkdir(recursive)` alone does
54
+ * not flush parent directory entries — a crash could lose an acknowledged blob
55
+ * or leave a surviving event pointing at a directory that never made it to disk.
56
+ */
57
+ export async function mkdirpDurable(dir) {
58
+ const missing = [];
59
+ let cur = dir;
60
+ for (;;) {
61
+ try {
62
+ await access(cur);
63
+ break;
64
+ }
65
+ catch {
66
+ missing.push(cur);
67
+ }
68
+ const parent = dirname(cur);
69
+ if (parent === cur)
70
+ break;
71
+ cur = parent;
72
+ }
73
+ try {
74
+ for (const d of missing.reverse()) {
75
+ try {
76
+ await mkdir(d, { mode: DIR_MODE });
77
+ }
78
+ catch (err) {
79
+ if (err.code !== 'EEXIST')
80
+ throw err;
81
+ continue; // a concurrent creator won; nothing new to flush here
82
+ }
83
+ await fsyncDir(dirname(d)); // persist the new entry in its parent
84
+ }
85
+ }
86
+ catch (err) {
87
+ throw err instanceof StorageError ? err : new StorageError(`mkdirp(${dir})`, err);
88
+ }
89
+ }
90
+ /**
91
+ * Reject a storage path that is not exclusively the owner's: a symlink (could
92
+ * redirect writes outside the store), group/other-accessible modes, or a
93
+ * different owner. The store holds real captured source bytes, so lax
94
+ * permissions on an *existing* path are a hard startup error (Q5).
95
+ */
96
+ export async function assertOwnerOnly(path, kind) {
97
+ const st = await lstat(path);
98
+ if (st.isSymbolicLink()) {
99
+ throw new StorageError(`insecure-path(${path})`, new Error('storage path is a symlink'));
100
+ }
101
+ if (kind === 'dir' ? !st.isDirectory() : !st.isFile()) {
102
+ throw new StorageError(`insecure-path(${path})`, new Error(`storage path is not a ${kind}`));
103
+ }
104
+ if ((st.mode & 0o077) !== 0) {
105
+ throw new StorageError(`insecure-path(${path})`, new Error('storage path is group/other-accessible'));
106
+ }
107
+ const uid = process.getuid?.();
108
+ if (uid !== undefined && st.uid !== uid) {
109
+ throw new StorageError(`insecure-path(${path})`, new Error('storage path is owned by another user'));
110
+ }
111
+ }
@@ -0,0 +1,185 @@
1
+ import { constants } from 'node:fs';
2
+ import { readdir, readFile, open } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { PUBLIC_EVENT_TYPES as EVENT_TYPES } from "./public-events.js";
6
+ import { LogCorruptError } from "./log-reader.js";
7
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
8
+ const HEX_RE = /^[0-9a-f]{64}$/;
9
+ const SEQ_RE = /^[1-9][0-9]*$/;
10
+ const SCHEMAS_DIR = fileURLToPath(new URL('../schemas/', import.meta.url));
11
+ const PROJECTIONS_DIR = fileURLToPath(new URL('../schemas/projections/', import.meta.url));
12
+ const INTERFACE_SCHEMA = fileURLToPath(new URL('../contracts/interface/v2/schema.json', import.meta.url));
13
+ /** The published projection schemas, by projection_version. Not gated to
14
+ * EVENT_TYPES: a projection is a reader-derived view, not a log event. */
15
+ const PROJECTION_SCHEMAS = new Set(['clip.v1', 'clip.v2', 'clip.v3']);
16
+ export function isValidSessionId(id) { return UUID_RE.test(id); }
17
+ export function isValidHex(hex) { return HEX_RE.test(hex); }
18
+ function sessionsDir(storeDir) { return join(storeDir, 'sessions'); }
19
+ export function sessionLogPath(storeDir, id) {
20
+ return join(sessionsDir(storeDir), id, 'events.jsonl');
21
+ }
22
+ export function tombstonePath(storeDir, id) {
23
+ return join(sessionsDir(storeDir), id, 'removed.json');
24
+ }
25
+ export function blobPath(storeDir, hex) {
26
+ return join(storeDir, 'blobs', 'sha256', hex.slice(0, 2), hex);
27
+ }
28
+ export async function onDiskHighWater(logPath) {
29
+ let handle;
30
+ try {
31
+ handle = await open(logPath, 'r');
32
+ }
33
+ catch (err) {
34
+ if (err.code === 'ENOENT')
35
+ return 0n;
36
+ throw err;
37
+ }
38
+ let lastLine;
39
+ try {
40
+ // Scan backwards in fixed-size windows, discarding a torn tail without
41
+ // retaining it. Only the final complete record needs to be assembled.
42
+ let pos = (await handle.stat()).size;
43
+ let foundEnd = false;
44
+ const pieces = [];
45
+ while (pos > 0) {
46
+ const size = Math.min(pos, 64 * 1024);
47
+ pos -= size;
48
+ const buffer = Buffer.allocUnsafe(size);
49
+ const { bytesRead } = await handle.read(buffer, 0, size, pos);
50
+ if (bytesRead !== size)
51
+ throw new LogCorruptError('high-water: log truncated during read');
52
+ let end = size;
53
+ if (!foundEnd) {
54
+ end = buffer.lastIndexOf(0x0a);
55
+ if (end < 0)
56
+ continue;
57
+ foundEnd = true;
58
+ }
59
+ const start = end > 0 ? buffer.lastIndexOf(0x0a, end - 1) : -1;
60
+ pieces.push(buffer.subarray(start + 1, end));
61
+ if (start >= 0)
62
+ break;
63
+ }
64
+ if (!foundEnd)
65
+ return 0n;
66
+ lastLine = Buffer.concat(pieces.reverse()).toString('utf8');
67
+ }
68
+ finally {
69
+ await handle.close();
70
+ }
71
+ if (lastLine === '')
72
+ throw new LogCorruptError('high-water: blank final record');
73
+ let seq;
74
+ try {
75
+ seq = JSON.parse(lastLine).seq;
76
+ }
77
+ catch {
78
+ throw new LogCorruptError('high-water: invalid JSON in final record');
79
+ }
80
+ if (typeof seq !== 'string' || !SEQ_RE.test(seq)) {
81
+ throw new LogCorruptError('high-water: final record has no valid decimal-string seq');
82
+ }
83
+ return BigInt(seq);
84
+ }
85
+ export async function readTombstone(storeDir, id) {
86
+ try {
87
+ const raw = await readFile(tombstonePath(storeDir, id), 'utf8');
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(raw);
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
96
+ && parsed.version === 1 ? { version: 1 } : null;
97
+ }
98
+ catch (err) {
99
+ if (err.code === 'ENOENT')
100
+ return null;
101
+ throw err;
102
+ }
103
+ }
104
+ export async function listSessions(storeDir) {
105
+ let entries;
106
+ try {
107
+ entries = await readdir(sessionsDir(storeDir));
108
+ }
109
+ catch (err) {
110
+ if (err.code === 'ENOENT')
111
+ return [];
112
+ throw err;
113
+ }
114
+ const out = [];
115
+ for (const id of entries) {
116
+ if (!isValidSessionId(id))
117
+ continue;
118
+ const removed = (await readTombstone(storeDir, id)) !== null;
119
+ // A removed session advertises no history. Short-circuit to 0 without reading
120
+ // high-water: a delete interrupted before cleanup can leave a corrupt residual
121
+ // log under a durable tombstone, and that must never break listing.
122
+ const durableSeq = removed ? 0n : await onDiskHighWater(sessionLogPath(storeDir, id));
123
+ out.push({ id, durableSeq, removed });
124
+ }
125
+ out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
126
+ return out;
127
+ }
128
+ export async function schemaBytes(type) {
129
+ if (!EVENT_TYPES.includes(type))
130
+ return null;
131
+ return readFile(join(SCHEMAS_DIR, `${type}.json`));
132
+ }
133
+ export async function projectionSchemaBytes(version) {
134
+ if (version === 'interface.v2')
135
+ return readFile(INTERFACE_SCHEMA);
136
+ if (!PROJECTION_SCHEMAS.has(version))
137
+ return null;
138
+ return readFile(join(PROJECTIONS_DIR, `${version}.json`));
139
+ }
140
+ export async function readRuntimeDescriptor(storeDir) {
141
+ const dir = join(storeDir, 'runtime');
142
+ let files;
143
+ try {
144
+ files = (await readdir(dir)).filter((f) => f.endsWith('.json'));
145
+ }
146
+ catch (err) {
147
+ if (err.code === 'ENOENT')
148
+ return null;
149
+ throw err;
150
+ }
151
+ let newest = null;
152
+ let newestMs = -1;
153
+ for (const f of files) {
154
+ // Validate the same fd we read; never follow symlinks or block on a FIFO.
155
+ let handle;
156
+ try {
157
+ handle = await open(join(dir, f), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
158
+ const info = await handle.stat();
159
+ if (!info.isFile() || (info.mode & 0o077) !== 0
160
+ || (process.getuid && info.uid !== process.getuid()))
161
+ continue;
162
+ const parsed = JSON.parse(await handle.readFile('utf8'));
163
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
164
+ continue;
165
+ const { url, token } = parsed;
166
+ if (typeof url !== 'string' || typeof token !== 'string')
167
+ continue;
168
+ const origin = new URL(url);
169
+ if (origin.protocol !== 'http:' || !['127.0.0.1', '[::1]', 'localhost'].includes(origin.hostname)
170
+ || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash)
171
+ continue;
172
+ if (info.mtimeMs > newestMs) {
173
+ newestMs = info.mtimeMs;
174
+ newest = { url, token };
175
+ }
176
+ }
177
+ catch {
178
+ // A stale, inaccessible or malformed candidate must not hide a usable one.
179
+ }
180
+ finally {
181
+ await handle?.close();
182
+ }
183
+ }
184
+ return newest;
185
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Loads the pinned Swift Tree-sitter grammar through the existing web-tree-sitter
3
+ * runtime and reports parse diagnostics with UTF-8 byte spans. This is the
4
+ * T5a.3 feasibility loader (STAGE-T-PREREQS 3.3): it proves the bundled artifact
5
+ * loads and parses; it does NOT extract declarations or make any comparison
6
+ * claim (that is T5a.4).
7
+ *
8
+ * IMPORTANT: loading + parsing this grammar deterministically aborts a default
9
+ * Node process a second or two later, inside V8's optimizing WASM compiler
10
+ * (turboshaft) with `Fatal process out of memory: Zone`. The only mitigation
11
+ * found is launching the host process with `node --liftoff-only` (single-tier
12
+ * baseline WASM); workers inherit it. So `loadSwiftLanguage`/`parseSwiftSource`
13
+ * must only ever execute inside such a process (see src/swift-parse-host.ts).
14
+ * Merely importing this module is safe — nothing here initializes eagerly, not
15
+ * even resolving the artifact path — and `loadSwiftLanguage` refuses to run
16
+ * outside a `--liftoff-only` process so an accidental in-process load fails loud
17
+ * instead of aborting the host a second later.
18
+ */
19
+ import { createRequire } from 'node:module';
20
+ import { readFileSync, existsSync } from 'node:fs';
21
+ import { dirname, join } from 'node:path';
22
+ import { createHash } from 'node:crypto';
23
+ import { Parser, Language, LANGUAGE_VERSION, MIN_COMPATIBLE_VERSION } from 'web-tree-sitter';
24
+ import { buildUtf16ToByteTable, utf16RangeToByteRange } from "./swift-spans.js";
25
+ const require = createRequire(import.meta.url);
26
+ /** Package specifier for the bundled artifact, resolved lazily (see
27
+ * `swiftWasmPath`) so importing this module never touches the filesystem. */
28
+ const SWIFT_WASM_SPECIFIER = 'tree-sitter-wasms/out/tree-sitter-swift.wasm';
29
+ /** Env escape that lets a NON-`--liftoff-only` process load the grammar anyway.
30
+ * Set by ONLY the OOM negative control (src/swift-parse.ts), which must reach
31
+ * the load to prove the default launch aborts. Nothing else should set it. */
32
+ export const SWIFT_UNISOLATED_ENV = 'SLIPSTREAM_SWIFT_ALLOW_UNISOLATED';
33
+ /** sha256 of the pinned WASM — the artifact's exact identity. The npm range the
34
+ * wrapper declares (`^0.4.0`) is not an exact grammar revision, so this hash,
35
+ * not a version string, is the real pin. */
36
+ export const EXPECTED_SHA256 = '41c4fdb2249a3aa6d87eed0d383081ff09725c2248b4977043a43825980ffcc7';
37
+ /** ABI version the pinned artifact must report. */
38
+ export const EXPECTED_ABI = 13;
39
+ /** Static provenance. The grammar and its wrapper carry DIFFERENT licenses and
40
+ * must not be conflated. The exact grammar revision that produced this WASM is
41
+ * not recoverable from the distributed artifact (the wrapper ships only `/out`,
42
+ * no lockfile), so it is honestly recorded as "unknown". */
43
+ export const GRAMMAR = {
44
+ package: 'tree-sitter-swift',
45
+ repo: 'https://github.com/alex-pinkus/tree-sitter-swift',
46
+ versionRange: '^0.4.0',
47
+ revision: 'unknown',
48
+ license: 'MIT',
49
+ };
50
+ export const WRAPPER = {
51
+ package: 'tree-sitter-wasms',
52
+ license: 'Unlicense',
53
+ };
54
+ /** A load/verification failure with the full context needed to diagnose it.
55
+ * Never caught-and-ignored: the loader never silently falls back. */
56
+ export class SwiftArtifactError extends Error {
57
+ detail;
58
+ constructor(message, detail, options) {
59
+ super(message, options);
60
+ this.name = 'SwiftArtifactError';
61
+ this.detail = detail;
62
+ }
63
+ }
64
+ /** Resolve the bundled WASM path lazily, wrapping a resolution failure as a
65
+ * `SwiftArtifactError` (never a raw `MODULE_NOT_FOUND`). Memoized. Called only
66
+ * from `loadSwiftLanguage`, so a missing install surfaces as the host's exit-2
67
+ * artifact failure rather than an uncaught throw during module import. */
68
+ let wasmPathCache;
69
+ export function swiftWasmPath() {
70
+ if (wasmPathCache)
71
+ return wasmPathCache;
72
+ try {
73
+ wasmPathCache = require.resolve(SWIFT_WASM_SPECIFIER);
74
+ }
75
+ catch (error) {
76
+ throw new SwiftArtifactError('pinned Swift WASM not found', {
77
+ specifier: SWIFT_WASM_SPECIFIER,
78
+ }, { cause: error });
79
+ }
80
+ return wasmPathCache;
81
+ }
82
+ /** Refuse to load the grammar outside a `--liftoff-only` process. Loading here
83
+ * would abort the process a second later inside V8's WASM tier-up (see the file
84
+ * header); failing fast keeps the boundary real instead of by convention. The
85
+ * `--liftoff-only` flag is inherited by worker threads (verified). */
86
+ function assertIsolatedProcess() {
87
+ if (process.execArgv.includes('--liftoff-only'))
88
+ return;
89
+ if (process.env[SWIFT_UNISOLATED_ENV] === '1')
90
+ return;
91
+ throw new SwiftArtifactError('refusing to load the Swift grammar outside a --liftoff-only process', {
92
+ execArgv: process.execArgv,
93
+ hint: 'load via runSwiftParseChild (src/swift-parse.ts), which spawns the isolated host',
94
+ });
95
+ }
96
+ /** Fail loudly if the bytes on disk are not the pinned artifact. Pure so the
97
+ * mismatch path is unit-testable without touching the WASM runtime. */
98
+ export function verifyArtifactHash(actualSha, expectedSha, path) {
99
+ if (actualSha !== expectedSha) {
100
+ throw new SwiftArtifactError('Swift WASM sha256 mismatch', {
101
+ path,
102
+ expectedSha256: expectedSha,
103
+ actualSha256: actualSha,
104
+ });
105
+ }
106
+ }
107
+ /** Fail loudly on an ABI outside the runtime's supported range, or not exactly
108
+ * the pinned ABI. Pure so both failure paths are unit-testable. */
109
+ export function checkAbi(actual, expected, supported, ctx) {
110
+ if (actual < supported.min || actual > supported.max) {
111
+ throw new SwiftArtifactError('Swift grammar ABI outside runtime compatibility range', {
112
+ ...ctx,
113
+ actualAbi: actual,
114
+ supportedAbi: supported,
115
+ });
116
+ }
117
+ if (actual !== expected) {
118
+ throw new SwiftArtifactError('Swift grammar ABI is not the pinned version', {
119
+ ...ctx,
120
+ actualAbi: actual,
121
+ expectedAbi: expected,
122
+ supportedAbi: supported,
123
+ });
124
+ }
125
+ }
126
+ function installedVersion(pkg) {
127
+ // Some packages (web-tree-sitter) block `./package.json` in their exports map,
128
+ // so resolve the package entry and walk up to the nearest package.json instead.
129
+ try {
130
+ return readVersionAt(require.resolve(`${pkg}/package.json`));
131
+ }
132
+ catch {
133
+ try {
134
+ let dir = dirname(require.resolve(pkg));
135
+ for (;;) {
136
+ const candidate = join(dir, 'package.json');
137
+ if (existsSync(candidate)) {
138
+ const v = readVersionAt(candidate);
139
+ if (v !== 'unknown')
140
+ return v;
141
+ }
142
+ const parent = dirname(dir);
143
+ if (parent === dir)
144
+ break;
145
+ dir = parent;
146
+ }
147
+ }
148
+ catch {
149
+ // fall through to 'unknown'
150
+ }
151
+ return 'unknown';
152
+ }
153
+ }
154
+ function readVersionAt(pkgJsonPath) {
155
+ const json = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
156
+ return json.version ?? 'unknown';
157
+ }
158
+ let initPromise;
159
+ /** Cache the one web-tree-sitter runtime init, matching the clip parser's
160
+ * pattern. Swift runs isolated in its own process, so there is no in-process
161
+ * clip parser to double-init with. */
162
+ function ensureInit() {
163
+ initPromise ??= Parser.init().catch((error) => {
164
+ initPromise = undefined;
165
+ throw error;
166
+ });
167
+ return initPromise;
168
+ }
169
+ /** Read → hash → verify → load the SAME bytes → verify ABI. Hashing the bytes
170
+ * that are actually loaded (rather than hashing a path and re-reading it) closes
171
+ * the verification gap Codex flagged. */
172
+ export async function loadSwiftLanguage() {
173
+ assertIsolatedProcess();
174
+ const path = swiftWasmPath();
175
+ const bytes = readFileSync(path);
176
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
177
+ verifyArtifactHash(sha256, EXPECTED_SHA256, path);
178
+ await ensureInit();
179
+ const supported = { min: MIN_COMPATIBLE_VERSION, max: LANGUAGE_VERSION };
180
+ let language;
181
+ try {
182
+ language = await Language.load(bytes);
183
+ }
184
+ catch (error) {
185
+ throw new SwiftArtifactError('Language.load failed for the Swift WASM', {
186
+ path,
187
+ sha256,
188
+ supportedAbi: supported,
189
+ }, { cause: error });
190
+ }
191
+ const abiVersion = language.abiVersion;
192
+ // The compatibility-range check lives in setLanguage(); run it explicitly so
193
+ // an incompatible grammar fails here with full context, never at first parse.
194
+ const probe = new Parser();
195
+ try {
196
+ probe.setLanguage(language);
197
+ }
198
+ catch (error) {
199
+ probe.delete();
200
+ throw new SwiftArtifactError('Parser.setLanguage rejected the Swift grammar ABI', {
201
+ path,
202
+ sha256,
203
+ abiVersion,
204
+ supportedAbi: supported,
205
+ }, { cause: error });
206
+ }
207
+ probe.delete();
208
+ checkAbi(abiVersion, EXPECTED_ABI, supported, { path, sha256 });
209
+ return {
210
+ language,
211
+ provenance: {
212
+ path,
213
+ sha256,
214
+ abiVersion,
215
+ supportedAbi: supported,
216
+ grammar: GRAMMAR,
217
+ wrapper: { package: WRAPPER.package, version: installedVersion(WRAPPER.package), license: WRAPPER.license },
218
+ webTreeSitter: { package: 'web-tree-sitter', version: installedVersion('web-tree-sitter') },
219
+ },
220
+ };
221
+ }
222
+ /** Parse Swift source and collect its ERROR/MISSING diagnostics with byte spans.
223
+ * Visits EVERY node (including anonymous missing tokens), so no nested defect is
224
+ * hidden. `clean` is true only when the whole tree is free of ERROR/MISSING. */
225
+ export function parseSwiftSource(language, source) {
226
+ const parser = new Parser();
227
+ let tree = null;
228
+ try {
229
+ parser.setLanguage(language);
230
+ tree = parser.parse(source);
231
+ if (!tree)
232
+ throw new Error('parser returned no tree');
233
+ const table = buildUtf16ToByteTable(source);
234
+ const diagnostics = [];
235
+ const stack = [tree.rootNode];
236
+ while (stack.length) {
237
+ const node = stack.pop();
238
+ if (node.isError || node.isMissing) {
239
+ const { byteStart, byteEnd } = utf16RangeToByteRange(table, node.startIndex, node.endIndex);
240
+ diagnostics.push({
241
+ kind: node.isMissing ? 'missing' : 'error',
242
+ nodeType: node.type,
243
+ byteStart,
244
+ byteEnd,
245
+ startRow: node.startPosition.row,
246
+ startColumn: node.startPosition.column,
247
+ endRow: node.endPosition.row,
248
+ endColumn: node.endPosition.column,
249
+ });
250
+ }
251
+ for (let i = node.childCount - 1; i >= 0; i--) {
252
+ const child = node.child(i);
253
+ if (child)
254
+ stack.push(child);
255
+ }
256
+ }
257
+ diagnostics.sort((a, b) => a.byteStart - b.byteStart || a.byteEnd - b.byteEnd || a.kind.localeCompare(b.kind) || a.nodeType.localeCompare(b.nodeType));
258
+ const rootType = tree.rootNode.type;
259
+ const hasError = tree.rootNode.hasError;
260
+ // clean requires BOTH signals to agree; disagreement is a defect, not "clean".
261
+ const clean = diagnostics.length === 0 && !hasError;
262
+ return { rootType, clean, diagnostics, byteLength: table.bytes[table.length] };
263
+ }
264
+ finally {
265
+ tree?.delete();
266
+ parser.delete();
267
+ }
268
+ }