pi-supernova 0.0.11 → 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.
package/surface.js CHANGED
@@ -68,25 +68,34 @@ const JS_DECL_PATTERNS = [
68
68
  ];
69
69
  // Module-level tables/constants (column 0 only): without them the previous declaration's span swallows them.
70
70
  const JS_TOP_LEVEL_BINDING = /^(const|let|var)\s+([a-zA-Z0-9_$]+)\s*=/;
71
+ // Indented methods (object-literal adapters, class members) that open a block on the same line.
72
+ const JS_METHOD = /^(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?\*?([a-zA-Z_$][\w$]*)\s*\([^()]*\)\s*\{$/;
73
+ const JS_ARROW_PROPERTY = /^([a-zA-Z_$][\w$]*)\s*[:=]\s*(?:async\s+)?(?:\([^()]*\)|[a-zA-Z_$][\w$]*)\s*=>\s*\{$/;
74
+ const NOT_METHOD_NAMES = new Set(["if", "for", "while", "switch", "catch", "function", "return", "else", "do", "try", "with", "await", "typeof", "new", "constructor"]);
75
+
76
+ function methodItem(line, lineNumber, depth) {
77
+ const match = JS_METHOD.exec(line) || JS_ARROW_PROPERTY.exec(line);
78
+ if (!match || NOT_METHOD_NAMES.has(match[1])) return null;
79
+ return { kind: "method", name: match[1], isExport: false, signature: line.replace(/\s*\{$/, ""), line: lineNumber, depth };
80
+ }
81
+
82
+ function declarationItem(line, rawLine, lineNumber) {
83
+ const patterns = /^\S/.test(rawLine) ? [...JS_DECL_PATTERNS, [JS_TOP_LEVEL_BINDING, false]] : JS_DECL_PATTERNS;
84
+ for (const [pattern, isExport] of patterns) {
85
+ const match = pattern.exec(line);
86
+ if (match) return { kind: match[1], name: match[2], isExport, signature: line.replace(/\{.*$/, "").trim(), line: lineNumber, depth: 0 };
87
+ }
88
+ return null;
89
+ }
71
90
 
72
91
  function scanJavaScript(lines) {
73
92
  const items = [];
74
93
  for (let i = 0; i < lines.length; i++) {
75
94
  const line = lines[i].trim();
76
95
  if (!line || line.startsWith("//") || line.startsWith("/*") || line.startsWith("*")) continue;
77
- const patterns = /^\S/.test(lines[i]) ? [...JS_DECL_PATTERNS, [JS_TOP_LEVEL_BINDING, false]] : JS_DECL_PATTERNS;
78
- for (const [pattern, isExport] of patterns) {
79
- const match = pattern.exec(line);
80
- if (!match) continue;
81
- items.push({
82
- kind: match[1],
83
- name: match[2],
84
- isExport,
85
- signature: line.replace(/\{.*$/, "").trim(),
86
- line: i + 1,
87
- });
88
- break;
89
- }
96
+ const indent = lines[i].length - lines[i].trimStart().length;
97
+ const item = declarationItem(line, lines[i], i + 1) || (indent > 0 && indent <= 8 ? methodItem(line, i + 1, 1) : null);
98
+ if (item) items.push(item);
90
99
  }
91
100
  return items;
92
101
  }
package/vfs.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
+ import { isString } from "./decode.js";
4
+ import { randomUUID } from "node:crypto";
3
5
 
4
6
  const VFS_CACHE_MAX = 1024;
5
7
 
@@ -8,13 +10,17 @@ export class CausalVfs {
8
10
  this.cache = new Map();
9
11
  this.overlays = [];
10
12
  this.onNewFile = onNewFile;
13
+ this.closed = false;
14
+ this.signal = undefined;
15
+ }
16
+
17
+ assertWritable() {
18
+ if (this.closed) throw new Error("program is already complete");
19
+ this.signal?.throwIfAborted();
11
20
  }
12
21
 
13
22
  setCache(target, content) {
14
- if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) {
15
- const oldest = this.cache.keys().next().value;
16
- if (oldest !== undefined) this.cache.delete(oldest);
17
- }
23
+ if (this.cache.size >= VFS_CACHE_MAX && !this.cache.has(target)) this.cache.delete(this.cache.keys().next().value);
18
24
  this.cache.set(target, content);
19
25
  }
20
26
 
@@ -22,34 +28,25 @@ export class CausalVfs {
22
28
  for (let i = this.overlays.length - 1; i >= 0; i--) {
23
29
  if (this.overlays[i].has(target)) return this.overlays[i].get(target);
24
30
  }
25
- return undefined;
26
31
  }
27
32
 
28
33
  getOverlayPaths() {
29
- const paths = new Set();
30
- for (const overlay of this.overlays) {
31
- for (const target of overlay.keys()) paths.add(target);
32
- }
33
- return [...paths];
34
+ return [...new Set(this.overlays.flatMap(overlay => [...overlay.keys()]))];
34
35
  }
35
36
 
36
37
  async read(target) {
37
38
  const overlay = this.getOverlay(target);
38
39
  if (overlay !== undefined) return overlay;
39
-
40
- const cached = this.cache.get(target);
41
- if (cached !== undefined) return cached;
42
-
40
+ // External editors and captured tools can change a file between any two reads.
43
41
  try {
44
42
  const text = await fs.readFile(target, "utf8");
45
43
  this.setCache(target, text);
46
44
  return text;
47
45
  } catch (err) {
48
- if (err.code === "EISDIR") {
49
- throw new Error(`read path is a directory, not a file: ${target}`);
50
- }
46
+ this.cache.delete(target);
47
+ if (err.code === "EISDIR") throw new Error("read path is a directory, not a file: " + target);
51
48
  if (err.code === "ENOENT") {
52
- const missing = new Error(`no such file: ${target} (locate it with nova.call("glob", {pattern}) or snap(query))`);
49
+ const missing = new Error("no such file: " + target + ' (locate it with nova.call("glob", {pattern}) or snap(query))');
53
50
  missing.code = "ENOENT";
54
51
  throw missing;
55
52
  }
@@ -58,87 +55,129 @@ export class CausalVfs {
58
55
  }
59
56
 
60
57
  async write(target, content) {
61
- if (this.overlays.length > 0) {
62
- this.overlays[this.overlays.length - 1].set(target, content);
63
- return { speculative: true };
64
- }
65
-
66
- let existed = true;
58
+ this.assertWritable();
59
+ if (!isString(content)) throw new Error("write requires string content");
67
60
  try {
68
- const stat = await fs.stat(target);
69
- if (stat.isDirectory()) {
70
- throw new Error(`cannot write to a directory: ${target}`);
71
- }
61
+ if ((await fs.stat(target)).isDirectory()) throw new Error("cannot write to a directory: " + target);
72
62
  } catch (err) {
73
63
  if (err.code !== "ENOENT") throw err;
74
- existed = false;
75
64
  }
76
-
77
- await fs.mkdir(path.dirname(target), { recursive: true });
78
- await fs.writeFile(target, content, "utf8");
79
- this.setCache(target, content);
80
- if (!existed) this.onNewFile?.(target);
65
+ this.assertWritable();
66
+ if (this.overlays.length) {
67
+ this.overlays.at(-1).set(target, content);
68
+ return { speculative: true };
69
+ }
70
+ await this.flush(new Map([[target, content]]));
81
71
  return { speculative: false };
82
72
  }
83
73
 
84
74
  begin() {
75
+ this.assertWritable();
85
76
  this.overlays.push(new Map());
86
77
  return this.overlays.length;
87
78
  }
88
79
 
89
- async commit() {
90
- if (this.overlays.length === 0) return { committed: 0, depth: 0 };
91
- const top = this.overlays.pop();
92
- if (this.overlays.length > 0) {
93
- const parent = this.overlays[this.overlays.length - 1];
94
- for (const [k, v] of top.entries()) parent.set(k, v);
95
- return { committed: top.size, depth: this.overlays.length };
80
+ /** Stage every file and its backup before replacing any destination. */
81
+ async flush(writes) {
82
+ const staged = [];
83
+ const createdDirs = [];
84
+ let failed = false;
85
+ try {
86
+ for (const [logicalPath, content] of writes) {
87
+ this.signal?.throwIfAborted();
88
+ let target = logicalPath;
89
+ let stat;
90
+ try {
91
+ target = await fs.realpath(logicalPath);
92
+ stat = await fs.stat(target);
93
+ if (!stat.isFile()) throw new Error("cannot write to a non-file: " + logicalPath);
94
+ } catch (err) {
95
+ if (err.code !== "ENOENT") throw err;
96
+ }
97
+ const parent = path.dirname(target);
98
+ const missing = [];
99
+ let probe = parent;
100
+ for (;;) {
101
+ try { await fs.stat(probe); break; } catch (err) {
102
+ if (err.code !== "ENOENT") throw err;
103
+ missing.push(probe);
104
+ probe = path.dirname(probe);
105
+ }
106
+ }
107
+ await fs.mkdir(parent, { recursive: true });
108
+ createdDirs.push(...missing.reverse());
109
+ const token = ".supernova-" + randomUUID();
110
+ const entry = { logicalPath, target, content, temporary: path.join(parent, token + ".new"), backup: path.join(parent, token + ".bak"), existed: !!stat, replaced: false };
111
+ staged.push(entry);
112
+ await fs.writeFile(entry.temporary, content, { encoding: "utf8", flag: "wx", mode: stat ? stat.mode & 0o7777 : 0o666 });
113
+ if (stat) {
114
+ await fs.chmod(entry.temporary, stat.mode & 0o7777);
115
+ await fs.copyFile(target, entry.backup, fs.constants.COPYFILE_EXCL);
116
+ }
117
+ }
118
+ for (const entry of staged) {
119
+ this.signal?.throwIfAborted();
120
+ await fs.rename(entry.temporary, entry.target);
121
+ entry.replaced = true;
122
+ }
123
+ for (const entry of staged) this.setCache(entry.logicalPath, entry.content);
124
+ if (staged.length) this.onNewFile?.();
125
+ } catch (error) {
126
+ failed = true;
127
+ const recoveryErrors = [];
128
+ for (const entry of staged.toReversed()) {
129
+ if (!entry.replaced) continue;
130
+ try {
131
+ if (entry.existed) await fs.rename(entry.backup, entry.target);
132
+ else await fs.unlink(entry.target);
133
+ } catch (err) {
134
+ // Keep the backup if recovery fails; never delete the remaining original.
135
+ entry.keepBackup = true;
136
+ recoveryErrors.push(entry.target + ": " + err.message + " (backup: " + entry.backup + ")");
137
+ }
138
+ }
139
+ this.invalidateCache();
140
+ if (recoveryErrors.length) throw new AggregateError([error, ...recoveryErrors.map(message => new Error(message))], "commit failed: " + error.message + "; recovery failed: " + recoveryErrors.join("; "));
141
+ throw error;
142
+ } finally {
143
+ for (const entry of staged) {
144
+ await fs.rm(entry.temporary, { force: true }).catch(() => {});
145
+ if (!entry.keepBackup) await fs.rm(entry.backup, { force: true }).catch(() => {});
146
+ }
147
+ if (failed) for (const dir of createdDirs.toReversed()) await fs.rmdir(dir).catch(() => {});
96
148
  }
97
- for (const [filePath, fileContent] of top.entries()) {
98
- await fs.mkdir(path.dirname(filePath), { recursive: true });
99
- await fs.writeFile(filePath, fileContent, "utf8");
100
- this.setCache(filePath, fileContent);
149
+ }
150
+
151
+ async commit() {
152
+ if (!this.overlays.length) return { committed: 0, depth: 0 };
153
+ const top = this.overlays.at(-1);
154
+ if (this.overlays.length > 1) {
155
+ const parent = this.overlays[this.overlays.length - 2];
156
+ for (const [key, value] of top) parent.set(key, value);
157
+ } else {
158
+ await this.flush(top);
101
159
  }
102
- if (top.size > 0) this.onNewFile?.();
103
- return { committed: top.size, depth: 0 };
160
+ this.overlays.pop();
161
+ return { committed: top.size, depth: this.overlays.length };
104
162
  }
105
163
 
106
164
  rollback() {
107
- if (this.overlays.length === 0) return { rolledBack: 0, depth: 0 };
108
165
  const top = this.overlays.pop();
109
- return { rolledBack: top.size, depth: this.overlays.length };
166
+ return { rolledBack: top?.size ?? 0, depth: this.overlays.length };
110
167
  }
111
168
 
112
169
  async prepareExternalMutation(name) {
113
- if (this.overlays.length > 1) {
114
- throw new Error(`${name} cannot run inside nova.speculate because external mutations cannot be rolled back`);
115
- }
116
- if (this.overlays.length === 0) return false;
170
+ this.assertWritable();
171
+ if (this.overlays.length > 1) throw new Error(name + " cannot run inside nova.speculate because external mutations cannot be rolled back");
172
+ if (!this.overlays.length) return false;
117
173
  const pending = this.overlays[0];
118
- for (const [filePath, fileContent] of pending.entries()) {
119
- await fs.mkdir(path.dirname(filePath), { recursive: true });
120
- await fs.writeFile(filePath, fileContent, "utf8");
121
- this.setCache(filePath, fileContent);
122
- }
123
- if (pending.size > 0) this.onNewFile?.();
174
+ await this.flush(pending);
175
+ this.assertWritable();
124
176
  this.overlays[0] = new Map();
125
177
  return pending.size > 0;
126
178
  }
127
179
 
128
- invalidateCache() {
129
- this.cache.clear();
130
- }
131
-
132
- clear() {
133
- this.invalidateCache();
134
- this.overlays.length = 0;
135
- }
136
-
137
- getCacheSize() {
138
- return this.cache.size;
139
- }
140
-
141
- getOverlayDepth() {
142
- return this.overlays.length;
143
- }
180
+ invalidateCache() { this.cache.clear(); }
181
+ getCacheSize() { return this.cache.size; }
182
+ getOverlayDepth() { return this.overlays.length; }
144
183
  }
package/workspace.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
+ import { constants } from "node:os";
4
5
  import { isString } from "./decode.js";
5
6
 
6
7
  let cachedCwd = null;
@@ -41,6 +42,20 @@ async function realpathNearest(target) {
41
42
  }
42
43
  }
43
44
 
45
+ /** Workspace-relative path with "/" separators, the form every model-facing surface uses. */
46
+ export function relativeSlash(root, absolute) {
47
+ return (path.relative(root, absolute) || absolute).split(path.sep).join("/");
48
+ }
49
+
50
+ const TEST_SEGMENTS = new Set(["test", "tests", "__tests__", "spec"]);
51
+
52
+ /** One rule for "is this a test file" across snap, evidence, and outlines. */
53
+ export function isTestPath(filePath) {
54
+ const segments = filePath.split(/[\\/]/);
55
+ const base = segments[segments.length - 1];
56
+ return segments.some((s) => TEST_SEGMENTS.has(s)) || /\.(test|spec)\./.test(base);
57
+ }
58
+
44
59
  export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = false) {
45
60
  if (inputPath == null || !isString(inputPath) || !inputPath.trim()) {
46
61
  throw new Error(`${opName} requires path`);
@@ -67,63 +82,66 @@ export async function resolveWorkspacePath(cwd, inputPath, opName, allowRoot = f
67
82
  }
68
83
 
69
84
  export async function runCommand(argv, options = {}) {
85
+ options.signal?.throwIfAborted();
70
86
  const cwd = options.cwd || process.cwd();
71
87
  const timeoutMs = options.timeoutMs ?? 60_000;
72
88
  const maxOutputChars = options.maxOutputChars ?? 2 * 1024 * 1024;
73
- return await new Promise((resolve, reject) => {
89
+ return new Promise((resolve, reject) => {
74
90
  const child = spawn(argv[0], argv.slice(1), {
75
- cwd,
76
- env: process.env,
77
- stdio: ["ignore", "pipe", "pipe"],
91
+ cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32",
78
92
  });
79
93
  let stdout = "";
80
94
  let stderr = "";
81
95
  let settled = false;
82
96
  let outputTruncated = false;
83
- let onAbort;
84
-
97
+ let terminationError;
98
+ let escalation;
85
99
  const cleanup = () => {
86
100
  clearTimeout(timer);
87
- if (options.signal && onAbort) options.signal.removeEventListener("abort", onAbort);
101
+ clearTimeout(escalation);
102
+ options.signal?.removeEventListener("abort", onAbort);
88
103
  };
89
- const fail = (err) => {
104
+ const fail = error => {
90
105
  if (settled) return;
91
106
  settled = true;
92
107
  cleanup();
93
- reject(err);
108
+ reject(error);
109
+ };
110
+ const signalTree = signal => {
111
+ if (!child.pid) return;
112
+ if (process.platform === "win32") {
113
+ const killer = spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
114
+ killer.on("error", () => child.kill(signal));
115
+ } else {
116
+ try { process.kill(-child.pid, signal); } catch (err) { if (err.code !== "ESRCH") child.kill(signal); }
117
+ }
94
118
  };
119
+ const terminate = error => {
120
+ if (settled || terminationError) return;
121
+ terminationError = error;
122
+ signalTree("SIGTERM");
123
+ // Keep ownership after the direct child exits: descendants may ignore SIGTERM.
124
+ escalation = setTimeout(() => { signalTree("SIGKILL"); fail(error); }, 150);
125
+ };
126
+ const onAbort = () => terminate(new Error("aborted"));
127
+ const timer = setTimeout(() => terminate(new Error("command timed out after " + timeoutMs + "ms: " + argv.join(" "))), timeoutMs);
95
128
  const append = (current, chunk) => {
96
- const remaining = Math.max(0, maxOutputChars - current.length);
129
+ const remaining = Math.max(0, maxOutputChars - stdout.length - stderr.length);
97
130
  if (chunk.length > remaining) outputTruncated = true;
98
- return remaining > 0 ? current + chunk.slice(0, remaining) : current;
131
+ return remaining ? current + chunk.slice(0, remaining) : current;
99
132
  };
100
- const timer = setTimeout(() => {
101
- child.kill("SIGTERM");
102
- fail(new Error(`command timed out after ${timeoutMs}ms: ${argv.join(" ")}`));
103
- }, timeoutMs);
104
-
105
133
  child.stdout.setEncoding("utf8");
106
134
  child.stderr.setEncoding("utf8");
107
- child.stdout.on("data", (chunk) => {
108
- stdout = append(stdout, chunk);
109
- });
110
- child.stderr.on("data", (chunk) => {
111
- stderr = append(stderr, chunk);
112
- });
135
+ child.stdout.on("data", chunk => { stdout = append(stdout, chunk); });
136
+ child.stderr.on("data", chunk => { stderr = append(stderr, chunk); });
113
137
  child.on("error", fail);
114
- child.on("close", (code) => {
115
- if (settled) return;
138
+ child.on("close", (code, signal) => {
139
+ if (settled || terminationError) return;
116
140
  settled = true;
117
141
  cleanup();
118
- resolve({ stdout, stderr, exitCode: code ?? 0, outputTruncated });
142
+ resolve({ stdout, stderr, exitCode: code ?? (128 + (constants.signals[signal] ?? 1)), signal, outputTruncated });
119
143
  });
120
- if (options.signal) {
121
- onAbort = () => {
122
- child.kill("SIGTERM");
123
- fail(new Error("aborted"));
124
- };
125
- if (options.signal.aborted) onAbort();
126
- else options.signal.addEventListener("abort", onAbort, { once: true });
127
- }
144
+ options.signal?.addEventListener("abort", onAbort, { once: true });
145
+ if (options.signal?.aborted) onAbort();
128
146
  });
129
147
  }