prolog-notebook 0.1.1 → 0.3.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.
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ // The command line. Thin on purpose: argument parsing, files, and words for a
3
+ // terminal. Everything it does lives in src/run.js and src/export.js, so a VS
4
+ // Code "run all" and a future --check get the same behaviour without going
5
+ // through a shell (869ectt38, 869ectt3e).
6
+ import { createRequire } from 'node:module';
7
+ import { readFileSync, writeFileSync } from 'node:fs';
8
+ import { basename } from 'node:path';
9
+ import { parse, NotebookError } from '../src/format.js';
10
+ import { prologVersion } from '../src/engine.js';
11
+ import { buildLine, currentBuild } from '../src/build-info.js';
12
+ import { banner } from '../src/version.js';
13
+ import { exportSource } from '../src/export.js';
14
+ import { runNotebook, DEFAULT_LIMIT } from '../src/run.js';
15
+
16
+ // The engine is imported WHERE IT IS USED, never at the top. src/node.js pulls in
17
+ // 5.9 MB of WebAssembly at module scope, so a static import here would mean that
18
+ // `--help` on a broken install fails before it can print anything — and the two
19
+ // commands most likely to be typed at a broken install are --help and --version.
20
+ const engine = () => import('../src/node.js');
21
+
22
+ // `prolog-notebook run --stdout file | head` closes the pipe while we are still
23
+ // writing to it. That is the reader using the shell correctly, not an error, and
24
+ // a command that answers it with an unhandled EPIPE and a stack trace is
25
+ // complaining about being used properly.
26
+ for (const stream of [process.stdout, process.stderr]) {
27
+ stream.on('error', (e) => {
28
+ if (e.code !== 'EPIPE') throw e;
29
+ });
30
+ }
31
+
32
+ // Only for swipl-wasm's own version: everything about THIS package is in
33
+ // src/version.js, where a page can import it too.
34
+ const require = createRequire(import.meta.url);
35
+
36
+ const USAGE = `prolog-notebook — Jupyter-style notebooks for Prolog
37
+
38
+ prolog-notebook run <file.prolog.md>... run every cell, write the answers back
39
+
40
+ Options
41
+ --limit <n> solutions to take from one query before stopping (default ${DEFAULT_LIMIT})
42
+ --stdout print the result instead of writing the file
43
+ --quiet report only failures
44
+ --version version, engine and copyright
45
+ -h, --help this
46
+
47
+ A query that stops at the limit is written without a terminator, which is the
48
+ format's way of saying the search was never exhausted. Nothing is invented.
49
+ `;
50
+
51
+ /**
52
+ * A runaway goal hangs this process — the engine is in-process here, so there is
53
+ * no thread left to notice (869ejgyax). Stated rather than implied, because the
54
+ * moment this runs a file someone else wrote it stops being an annoyance.
55
+ */
56
+ const RUNAWAY_WARNING = 'note: a non-terminating goal will hang this command; it has no timeout yet (869ejgyax)';
57
+
58
+ /**
59
+ * Who this is, and — the part that is not on anyone's disk — which Prolog it
60
+ * will run your chapters with.
61
+ *
62
+ * THE ENGINE LINE EARNS ITS 59 MILLISECONDS. swipl-wasm's own version says
63
+ * nothing about SWI's: 8.0.4 ships 10.1.10. A notebook's saved answers are only
64
+ * true of the engine that produced them, so this is the one fact here that a
65
+ * reader could not have looked up.
66
+ *
67
+ * An engine that will not load is REPORTED, not fatal. "I cannot start Prolog"
68
+ * is exactly what someone running --version to diagnose a broken install needs
69
+ * to be told, and exiting non-zero would hide it behind a shell error.
70
+ */
71
+ async function version() {
72
+ // The same line the page shows in its panel: src/version.js, imported by both.
73
+ const lines = [banner()];
74
+ try {
75
+ const { createSession } = await engine();
76
+ const swipl = await prologVersion(await createSession());
77
+ const wasm = `swipl-wasm ${require('swipl-wasm/package.json').version}`;
78
+ lines.push(swipl ? `Powered by SWI-Prolog ${swipl}, ${wasm}` : `Powered by ${wasm}`);
79
+ } catch (e) {
80
+ // Not "powered by" anything, so it does not say so. Someone running this to
81
+ // find out why nothing works needs the reason, not a formula.
82
+ lines.push(`SWI-Prolog could not be started: ${e.message}`);
83
+ }
84
+ // Omitted rather than guessed at when there is neither a baked file nor a git
85
+ // repository — a line that says "unknown" three times is worse than no line.
86
+ lines.push(buildLine(currentBuild()));
87
+ // The blank line is deliberate: this is a banner, and a banner that runs into
88
+ // the next shell prompt reads as an error message.
89
+ return `${lines.join('\n')}\n\n`;
90
+ }
91
+
92
+ async function main(argv) {
93
+ const args = argv.slice(2);
94
+ if (!args.length || args.includes('-h') || args.includes('--help')) {
95
+ process.stdout.write(USAGE);
96
+ return 0;
97
+ }
98
+ if (args.includes('--version') || args.includes('-V')) {
99
+ process.stdout.write(await version());
100
+ return 0;
101
+ }
102
+
103
+ const command = args.shift();
104
+ if (command !== 'run') {
105
+ process.stderr.write(`unknown command "${command}"\n\n${USAGE}`);
106
+ return 2;
107
+ }
108
+
109
+ const options = { limit: DEFAULT_LIMIT, stdout: false, quiet: false };
110
+ const files = [];
111
+ while (args.length) {
112
+ const arg = args.shift();
113
+ if (arg === '--limit') {
114
+ const value = Number(args.shift());
115
+ if (!Number.isInteger(value) || value < 1) {
116
+ process.stderr.write('--limit takes a positive whole number\n');
117
+ return 2;
118
+ }
119
+ options.limit = value;
120
+ } else if (arg === '--stdout') options.stdout = true;
121
+ else if (arg === '--quiet') options.quiet = true;
122
+ else if (arg.startsWith('-')) {
123
+ process.stderr.write(`unknown option "${arg}"\n\n${USAGE}`);
124
+ return 2;
125
+ } else files.push(arg);
126
+ }
127
+
128
+ if (!files.length) {
129
+ process.stderr.write('run needs at least one file\n');
130
+ return 2;
131
+ }
132
+ if (!options.quiet) process.stderr.write(`${RUNAWAY_WARNING}\n`);
133
+
134
+ // One engine for the whole invocation, restarted between files. A notebook is
135
+ // a world of its own — one cell is one virtual file, and two chapters may
136
+ // define the same predicate — so carrying clauses across would let a file pass
137
+ // because of what the file before it happened to load.
138
+ const { createSession } = await engine();
139
+ const session = await createSession();
140
+ let status = 0;
141
+
142
+ for (const file of files) {
143
+ await session.restart();
144
+ status = Math.max(status, await runFile(file, session, options));
145
+ }
146
+ return status;
147
+ }
148
+
149
+ async function runFile(file, session, options) {
150
+ const name = basename(file);
151
+ let notebook;
152
+ let source;
153
+ try {
154
+ source = readFileSync(file, 'utf8');
155
+ notebook = parse(source);
156
+ } catch (e) {
157
+ // The parser's line numbers are the file's own, so its message is already
158
+ // the most useful thing anyone could say here.
159
+ process.stderr.write(`${file}: ${e instanceof NotebookError ? e.message : e.message}\n`);
160
+ return 1;
161
+ }
162
+
163
+ const { edits, failures, warnings } = await runNotebook(notebook, session, {
164
+ limit: options.limit,
165
+ onCell: (event) => {
166
+ if (options.quiet) return;
167
+ if (event.kind === 'program') {
168
+ process.stderr.write(` ${event.ok ? '✓' : '✗'} ${event.id}\n`);
169
+ return;
170
+ }
171
+ const answers = event.error
172
+ ? `error: ${event.error}`
173
+ : `${event.solutions.length} solution${event.solutions.length === 1 ? '' : 's'}`
174
+ + (event.truncated ? ` (stopped at ${options.limit}, not exhausted)` : '');
175
+ process.stderr.write(` ${event.error ? '✗' : '✓'} ${event.id} — ${answers}\n`);
176
+ },
177
+ });
178
+
179
+ for (const warning of warnings) process.stderr.write(` ! ${warning.id}: ${warning.text}\n`);
180
+
181
+ if (failures.length) {
182
+ // NOTHING IS WRITTEN when a program cell failed to load. Every answer below
183
+ // it was produced against a chapter that does not exist, and writing those
184
+ // into the file would publish them as though they did.
185
+ for (const failure of failures) {
186
+ process.stderr.write(`${file}: cell ${failure.id} did not load: ${failure.error}\n`);
187
+ }
188
+ process.stderr.write(`${name}: not written\n`);
189
+ return 1;
190
+ }
191
+
192
+ const text = exportSource(notebook, edits);
193
+ if (options.stdout) {
194
+ process.stdout.write(text);
195
+ return 0;
196
+ }
197
+ if (text === source) {
198
+ if (!options.quiet) process.stderr.write(`${name}: unchanged\n`);
199
+ return 0;
200
+ }
201
+ writeFileSync(file, text);
202
+ if (!options.quiet) process.stderr.write(`${name}: written\n`);
203
+ return 0;
204
+ }
205
+
206
+ process.exitCode = await main(process.argv);
package/package.json CHANGED
@@ -1,16 +1,27 @@
1
1
  {
2
2
  "name": "prolog-notebook",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Jupyter-style notebooks for Prolog. Runs in the browser, installs nothing.",
5
5
  "type": "module",
6
6
  "main": "./src/node.js",
7
+ "bin": {
8
+ "prolog-notebook": "./bin/prolog-notebook.mjs"
9
+ },
7
10
  "exports": {
8
11
  ".": "./src/node.js",
9
12
  "./browser": "./src/browser.js",
10
13
  "./engine": "./src/engine.js",
11
- "./notebook.css": "./src/notebook.css"
14
+ "./format": "./src/format.js",
15
+ "./notebook.css": "./src/notebook.css",
16
+ "./page": "./src/page.js",
17
+ "./render": "./src/render.js",
18
+ "./session": "./src/session.js",
19
+ "./worker": "./src/worker.js",
20
+ "./run": "./src/run.js",
21
+ "./export": "./src/export.js"
12
22
  },
13
23
  "files": [
24
+ "bin",
14
25
  "src",
15
26
  "README.md",
16
27
  "LICENSE",
@@ -18,7 +29,7 @@
18
29
  ],
19
30
  "scripts": {
20
31
  "test": "node --test test/*.test.mjs",
21
- "example": "python3 -m http.server 8777",
32
+ "dev": "python3 -m http.server 8777",
22
33
  "prepublishOnly": "npm test"
23
34
  },
24
35
  "keywords": [
@@ -45,9 +56,13 @@
45
56
  "node": ">=18"
46
57
  },
47
58
  "dependencies": {
48
- "swipl-wasm": "^8.0.4"
59
+ "markdown-it": "^15.0.0",
60
+ "swipl-wasm": "8.0.7"
49
61
  },
50
62
  "publishConfig": {
51
63
  "access": "public"
64
+ },
65
+ "devDependencies": {
66
+ "jsdom": "^29.1.1"
52
67
  }
53
68
  }
package/src/browser.js CHANGED
@@ -1,25 +1,261 @@
1
- // Browser entry point. The swipl-wasm bundle is loaded by a <script> tag and
2
- // exposes a global SWIPL factory; keeping the load out of this module means the
3
- // 5.9 MB bundle is fetched by the page, not by a bundler.
4
- import { PrologSession } from './engine.js';
1
+ // Browser entry point. The engine runs in a Web Worker, so a query that never
2
+ // terminates costs the reader a click on Stop rather than the whole tab.
3
+ //
4
+ // The 5.9 MB swipl bundle is still fetched by the page's own URL rather than by a
5
+ // bundler — the worker is told where to find it.
6
+ import { ConsultLog, defaultCellName, unconsult } from './session.js';
5
7
 
6
8
  export * from './engine.js';
9
+ export { ConsultLog } from './session.js';
7
10
 
8
- let session = null;
11
+ const DEFAULT_SWIPL_URL = new URL(
12
+ '../node_modules/swipl-wasm/dist/swipl/swipl-bundle.js',
13
+ import.meta.url
14
+ ).href;
9
15
 
10
16
  /**
11
- * Boot (once) and return the shared session for this page.
12
- * @returns {Promise<PrologSession>}
17
+ * A session whose engine lives in a worker.
18
+ *
19
+ * Every method returns a promise. `abort()` terminates the worker outright and
20
+ * replays the consult log into a new one, which is the only thing that works: a
21
+ * thread blocked inside WASM cannot be asked politely to stop.
13
22
  */
14
- export function createSession(options = {}) {
15
- if (!session) {
16
- if (typeof globalThis.SWIPL !== 'function') {
17
- throw new Error(
18
- 'swipl-wasm not found. Load it first, e.g.\n' +
19
- '<script src="node_modules/swipl-wasm/dist/swipl/swipl-bundle.js"></script>'
20
- );
23
+ export class WorkerSession {
24
+ #worker = null;
25
+ #pending = new Map();
26
+ #nextId = 1;
27
+ #booting = null;
28
+ /** The one query allowed to hold a frame. See supersede(). */
29
+ #open = null;
30
+
31
+ constructor({ workerUrl, swiplUrl = DEFAULT_SWIPL_URL, engineUrl, options = {} } = {}) {
32
+ this.workerUrl = workerUrl ?? new URL('./worker.js', import.meta.url).href;
33
+ this.swiplUrl = swiplUrl;
34
+ // The worker imports the engine itself, so it needs an absolute URL: a
35
+ // relative specifier would resolve against the worker script, which may have
36
+ // been served from anywhere.
37
+ this.engineUrl = engineUrl ?? new URL('./engine.js', import.meta.url).href;
38
+ this.options = options;
39
+ this.log = new ConsultLog();
40
+ }
41
+
42
+ /** Boot the worker and the engine inside it. Idempotent. */
43
+ async start() {
44
+ if (this.#worker) return this;
45
+ if (this.#booting) return this.#booting;
46
+ this.#booting = (async () => {
47
+ this.#spawn();
48
+ await this.#send('boot', {
49
+ swiplUrl: this.swiplUrl,
50
+ engineUrl: this.engineUrl,
51
+ options: this.options,
52
+ });
53
+ this.#booting = null;
54
+ return this;
55
+ })();
56
+ return this.#booting;
57
+ }
58
+
59
+ async consult(text, name = defaultCellName()) {
60
+ await this.start();
61
+ const result = await this.#send('consult', { text, name });
62
+ if (result.ok) this.log.record(name, text);
63
+ return result;
64
+ }
65
+
66
+ /**
67
+ * Open a query. Nothing runs until the first `next()` or `all()`, so opening
68
+ * one is always safe even if the goal is a disaster.
69
+ */
70
+ query(goal) {
71
+ return new WorkerQuery(this, goal);
72
+ }
73
+
74
+ /**
75
+ * ONE OPEN SEQUENCE PER SESSION, and the reason is not tidiness.
76
+ *
77
+ * SWI keeps open queries on a stack and swipl-wasm enforces it: stepping or
78
+ * closing anything but the innermost throws "Attempt to access not innermost
79
+ * query". A page cannot promise the order — the order is whatever the reader
80
+ * clicks — so the constraint is met by construction instead: there is never
81
+ * more than one open query, which means the one being closed is always the
82
+ * innermost, which means the close is always legal (869epzqpc).
83
+ *
84
+ * Called at the moment a frame is about to be opened, never when the query
85
+ * OBJECT is made: a cell whose Run fails before it ever steps must not end
86
+ * someone else's sequence for nothing.
87
+ *
88
+ * @internal
89
+ */
90
+ async supersede() {
91
+ const previous = this.#open;
92
+ this.#open = null;
93
+ if (!previous) return;
94
+ await previous.close({ superseded: true });
95
+ // Said only after the frame is actually gone, so a listener that starts a new
96
+ // query cannot race the close it was told about.
97
+ previous.onSuperseded?.();
98
+ }
99
+
100
+ /** @internal a query's frame is gone — exhausted, closed, or died with the engine. */
101
+ release(query) {
102
+ if (this.#open === query) this.#open = null;
103
+ }
104
+
105
+ /** @internal a query has just taken the session's one frame. */
106
+ hold(query) {
107
+ this.#open = query;
108
+ }
109
+
110
+ /** Take one cell's clauses back out. See unconsult() in session.js. */
111
+ async unconsult(name) {
112
+ return unconsult(this, name);
113
+ }
114
+
115
+ /**
116
+ * Throw the engine away and rebuild it from the consult log.
117
+ *
118
+ * Terminating is not a last resort here, it is the mechanism: it reclaims the
119
+ * whole WASM heap as well as the stuck goal, so a memory blow-up and an
120
+ * infinite loop have the same cure.
121
+ *
122
+ * A `:- dynamic` cell's assert/retract state does not survive this, which is
123
+ * already the documented behaviour of "restart engine and run all"
124
+ * (format §8) rather than a new surprise.
125
+ */
126
+ async restart() {
127
+ this.#teardown(new Error('aborted'));
128
+ // Every frame died with the worker, so nothing is holding the session's.
129
+ this.#open = null;
130
+ await this.start();
131
+ for (const { name, text } of this.log) {
132
+ await this.#send('consult', { text, name });
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Stop whatever is running. Identical to restart() here, and named separately
138
+ * because the two are different intentions: one rescues a page, the other
139
+ * throws away assert/retract state deliberately.
140
+ */
141
+ async abort() {
142
+ return this.restart();
143
+ }
144
+
145
+ async close() {
146
+ this.#teardown(new Error('session closed'));
147
+ }
148
+
149
+ #spawn() {
150
+ // Classic, not module: swipl-wasm has no ESM entry, and importScripts is the
151
+ // only way to get its global. See the comment at the top of worker.js.
152
+ this.#worker = new Worker(this.workerUrl);
153
+ this.#worker.onmessage = ({ data }) => {
154
+ const entry = this.#pending.get(data.id);
155
+ if (!entry) return;
156
+ this.#pending.delete(data.id);
157
+ if (data.ok) entry.resolve(data.value);
158
+ else entry.reject(new Error(data.error));
159
+ };
160
+ this.#worker.onerror = (event) => {
161
+ this.#teardown(new Error(event.message ?? 'worker failed'));
162
+ };
163
+ }
164
+
165
+ #teardown(reason) {
166
+ this.#open = null;
167
+ this.#worker?.terminate();
168
+ this.#worker = null;
169
+ this.#booting = null;
170
+ // Anything still waiting will never hear back, so say so rather than leaving
171
+ // a promise dangling forever — a silent hang is what this whole change exists
172
+ // to remove.
173
+ for (const { reject } of this.#pending.values()) reject(reason);
174
+ this.#pending.clear();
175
+ }
176
+
177
+ #send(op, args = {}) {
178
+ if (!this.#worker) return Promise.reject(new Error('worker is not running'));
179
+ const id = this.#nextId++;
180
+ return new Promise((resolve, reject) => {
181
+ this.#pending.set(id, { resolve, reject });
182
+ this.#worker.postMessage({ id, op, ...args });
183
+ });
184
+ }
185
+
186
+ /** @internal */
187
+ send(op, args) {
188
+ return this.#send(op, args);
189
+ }
190
+ }
191
+
192
+ class WorkerQuery {
193
+ #qid = null;
194
+
195
+ constructor(session, goal) {
196
+ this.session = session;
197
+ this.goal = goal;
198
+ this.done = false;
199
+ // Ended by another query taking the session's one frame, rather than by its
200
+ // own search finishing. Kept apart from `done` because only one of the two
201
+ // may ever be written down as an exhausted search (format §6).
202
+ this.superseded = false;
203
+ /** Set by the caller to hear that its sequence was closed for another one. */
204
+ this.onSuperseded = null;
205
+ }
206
+
207
+ async #open() {
208
+ if (this.#qid === null) {
209
+ await this.session.start();
210
+ // Before the frame exists, never after: once a second query is open the
211
+ // first is no longer innermost and can never be closed at all.
212
+ await this.session.supersede();
213
+ this.#qid = await this.session.send('open', { goal: this.goal });
214
+ this.session.hold(this);
21
215
  }
22
- session = PrologSession.create(globalThis.SWIPL, options);
216
+ return this.#qid;
23
217
  }
24
- return session;
218
+
219
+ async next() {
220
+ if (this.done) return this.superseded ? { done: true, superseded: true } : { done: true };
221
+ const qid = await this.#open();
222
+ const result = await this.session.send('next', { qid });
223
+ // The worker forgets a query that reports done — swipl-wasm has closed it —
224
+ // so the frame is already back and nothing here needs to ask for it.
225
+ if (result.done) this.#finish();
226
+ return result;
227
+ }
228
+
229
+ async all(limit) {
230
+ if (this.done) return { solutions: [], truncated: false };
231
+ const qid = await this.#open();
232
+ this.#finish();
233
+ return this.session.send('all', { qid, limit });
234
+ }
235
+
236
+ async close({ superseded = false } = {}) {
237
+ if (superseded) this.superseded = true;
238
+ if (this.#qid === null || this.done) {
239
+ this.#finish();
240
+ return;
241
+ }
242
+ this.#finish();
243
+ await this.session.send('close', { qid: this.#qid });
244
+ }
245
+
246
+ #finish() {
247
+ this.done = true;
248
+ this.session.release(this);
249
+ }
250
+ }
251
+
252
+ let shared = null;
253
+
254
+ /**
255
+ * Boot (once) and return the shared session for this page.
256
+ * @returns {Promise<WorkerSession>}
257
+ */
258
+ export function createSession(options = {}) {
259
+ if (!shared) shared = new WorkerSession(options).start();
260
+ return shared;
25
261
  }
@@ -0,0 +1,94 @@
1
+ // Which copy of this you are actually running.
2
+ //
3
+ // A version number answers "which release"; it does not answer "which of the
4
+ // four things on this machine claiming to be 0.2.0". A published install, a
5
+ // checkout with the branch still on it, and an npm-linked working copy with
6
+ // uncommitted edits are three different programs, and a bug report against the
7
+ // wrong one costs an afternoon.
8
+ //
9
+ // TWO STATES, NAMED, because they are not the same claim:
10
+ //
11
+ // Build ccf8e5b, committed 2026-08-30, packaged 2026-08-30
12
+ // Working copy ccf8e5b (modified), committed 2026-08-30
13
+ //
14
+ // The first is baked in by the release workflow just before publish — git exists
15
+ // there and does not exist inside an installed package. The second is read from
16
+ // git at run time, and says `(modified)` when the tree has edits, because a bare
17
+ // SHA over a dirty tree names a program that nobody has.
18
+ //
19
+ // NOTHING IS BUILT HERE. The package is plain ES modules, published as written,
20
+ // so "packaged" is the honest word for the third date — there is no compiler and
21
+ // no output to date-stamp. A working copy has no packaging time at all, which is
22
+ // why the second state has two fields rather than three.
23
+ import { execFileSync } from 'node:child_process';
24
+ import { readFileSync } from 'node:fs';
25
+
26
+ /** Where prepack leaves the facts. Inside `src`, so `files` already ships it. */
27
+ const BAKED = new URL('./build-info.json', import.meta.url);
28
+
29
+ /**
30
+ * The provenance line, or null when nothing is known.
31
+ *
32
+ * Pure, so both states can be tested without a filesystem or a git repository.
33
+ *
34
+ * @param {{commit: string, committed: string, packaged?: string, modified?: boolean}|null} info
35
+ * @returns {string|null}
36
+ */
37
+ export function buildLine(info) {
38
+ if (!info?.commit) return null;
39
+ if (info.packaged) {
40
+ return `Build ${info.commit}, committed ${info.committed}, packaged ${info.packaged}`;
41
+ }
42
+ return `Working copy ${info.commit}${info.modified ? ' (modified)' : ''}, committed ${info.committed}`;
43
+ }
44
+
45
+ /**
46
+ * What this copy is, from whichever of the two sources exists.
47
+ *
48
+ * Null rather than a guess when neither does — a tarball built before any of
49
+ * this existed, or a source tree with no history. A line that says "unknown"
50
+ * three times is worse than no line.
51
+ *
52
+ * @returns {{commit: string, committed: string, packaged?: string, modified?: boolean}|null}
53
+ */
54
+ export function currentBuild() {
55
+ try {
56
+ const baked = JSON.parse(readFileSync(BAKED, 'utf8'));
57
+ if (baked?.commit) return baked;
58
+ } catch {
59
+ // No file, or a damaged one. Either way git is the better authority here.
60
+ }
61
+ return fromGit();
62
+ }
63
+
64
+ /**
65
+ * Ask git, which is only there in a working copy.
66
+ *
67
+ * `execFileSync` with fixed arguments and no shell. It costs about ten
68
+ * milliseconds and only ever runs in development, where the alternative is a
69
+ * command that cannot tell you which of your own commits it is.
70
+ */
71
+ function fromGit() {
72
+ const root = new URL('..', import.meta.url);
73
+ const git = (...args) => execFileSync('git', ['-C', root.pathname, ...args], {
74
+ encoding: 'utf8',
75
+ stdio: ['ignore', 'pipe', 'ignore'],
76
+ }).trim();
77
+ try {
78
+ const [commit, committed] = git('log', '-1', '--format=%h %cs').split(' ');
79
+ return { commit, committed, modified: git('status', '--porcelain') !== '' };
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The facts, as prepack writes them. Exported so the script that runs at pack
87
+ * time and the code that reads the result agree on the shape.
88
+ *
89
+ * @param {{commit: string, committed: string}} head
90
+ * @param {Date} [now]
91
+ */
92
+ export function bakedFrom(head, now = new Date()) {
93
+ return { ...head, packaged: now.toISOString().slice(0, 10) };
94
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "commit": "ca69c2a",
3
+ "committed": "2026-08-30",
4
+ "packaged": "2026-08-30"
5
+ }