@bigknoxy/hashpilot 4.6.3
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/LICENSE +21 -0
- package/README.md +777 -0
- package/docs/ADAPTER-CONTRACT.md +1260 -0
- package/docs/ARCHITECTURE.md +846 -0
- package/docs/CLI-QUICKREF.md +827 -0
- package/docs/COMPETITIVE-ANALYSIS.md +307 -0
- package/docs/INSTALL.md +403 -0
- package/docs/INTEGRATION-CLAUDE.md +126 -0
- package/docs/INTEGRATION-MCP.md +196 -0
- package/docs/INTEGRATION-OPENCODE.md +136 -0
- package/docs/INTEGRATION-PI.md +195 -0
- package/package.json +77 -0
- package/scripts/build-site.sh +39 -0
- package/scripts/doctor.sh +218 -0
- package/scripts/gen-cli-quickref.ts +232 -0
- package/scripts/install-cli.sh +60 -0
- package/scripts/install.sh +466 -0
- package/scripts/roadmap-lint.ts +200 -0
- package/scripts/uninstall.sh +202 -0
- package/src/cli-node.cjs +51 -0
- package/src/cli.ts +209 -0
- package/src/commands/ast.ts +255 -0
- package/src/commands/diff.ts +98 -0
- package/src/commands/edit.ts +93 -0
- package/src/commands/hash.ts +64 -0
- package/src/commands/intent.ts +68 -0
- package/src/commands/maintenance.ts +191 -0
- package/src/commands/mcp.ts +28 -0
- package/src/commands/provenance.ts +111 -0
- package/src/commands/read.ts +117 -0
- package/src/commands/route.ts +42 -0
- package/src/commands/shared.ts +65 -0
- package/src/commands/telemetry.ts +126 -0
- package/src/commands/verify.ts +61 -0
- package/src/core/ast-edit.ts +2357 -0
- package/src/core/batch-edit.ts +185 -0
- package/src/core/config.ts +189 -0
- package/src/core/diff-engine.ts +474 -0
- package/src/core/doctor.ts +303 -0
- package/src/core/encoding.ts +116 -0
- package/src/core/envelope.ts +163 -0
- package/src/core/exit-codes.ts +198 -0
- package/src/core/format.ts +339 -0
- package/src/core/grep.ts +180 -0
- package/src/core/hash-edit.ts +416 -0
- package/src/core/index.ts +155 -0
- package/src/core/intent.ts +584 -0
- package/src/core/locking.ts +292 -0
- package/src/core/module-system.ts +142 -0
- package/src/core/operations.ts +557 -0
- package/src/core/output.ts +122 -0
- package/src/core/path-normalize.ts +61 -0
- package/src/core/paths.ts +326 -0
- package/src/core/plan-executor.ts +437 -0
- package/src/core/platform.ts +132 -0
- package/src/core/provenance.ts +214 -0
- package/src/core/read.ts +111 -0
- package/src/core/redact.ts +98 -0
- package/src/core/resolve-content.ts +12 -0
- package/src/core/router.ts +463 -0
- package/src/core/snapshot.ts +346 -0
- package/src/core/telemetry.ts +838 -0
- package/src/core/utils.ts +7 -0
- package/src/core/verify-baseline.ts +186 -0
- package/src/core/verify-scope.ts +282 -0
- package/src/core/verify.ts +753 -0
- package/src/mcp/server.ts +325 -0
- package/templates/claude-section.md +12 -0
- package/templates/opencode-agent.md +106 -0
- package/templates/opencode-skill.md +241 -0
- package/templates/pi-extension.ts +288 -0
- package/templates/pi-skill.md +123 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, statSync, readdirSync } from "fs";
|
|
2
|
+
import { createHash, randomBytes } from "crypto";
|
|
3
|
+
import { join, dirname, resolve as pathResolve } from "path";
|
|
4
|
+
import { findProjectRoot } from "./paths";
|
|
5
|
+
|
|
6
|
+
/** Lock directory, relative to the *target file's* project root — never to cwd. */
|
|
7
|
+
const LOCK_DIR_NAME = join(".hashpilot", "locks");
|
|
8
|
+
|
|
9
|
+
/** Maximum wait before abandoning a lock acquisition. */
|
|
10
|
+
export const LOCK_TIMEOUT_MS = 10_000;
|
|
11
|
+
|
|
12
|
+
/** How often to retry when waiting for a lock (ms). */
|
|
13
|
+
const LOCK_RETRY_MS = 50;
|
|
14
|
+
|
|
15
|
+
/** How often a held lock refreshes its `ts` so others can see it is alive. */
|
|
16
|
+
const HEARTBEAT_MS = 5_000;
|
|
17
|
+
|
|
18
|
+
/** If the holder hasn't refreshed `ts` in this many ms, treat the lock as stale. */
|
|
19
|
+
const STALE_THRESHOLD_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/** On-disk lock payload. */
|
|
22
|
+
interface LockPayload {
|
|
23
|
+
pid: number;
|
|
24
|
+
/** Per-acquisition token. Release only unlinks a file still carrying our nonce. */
|
|
25
|
+
nonce: string;
|
|
26
|
+
ts: number;
|
|
27
|
+
targets: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Errors thrown by this module. */
|
|
31
|
+
export class LockAcquireError extends Error {
|
|
32
|
+
public readonly reason: "timeout" | "stale";
|
|
33
|
+
|
|
34
|
+
constructor(message: string, reason: "timeout" | "stale") {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "LockAcquireError";
|
|
37
|
+
this.reason = reason;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Derive the lock-file path for a target file.
|
|
43
|
+
*
|
|
44
|
+
* The key is a SHA-256 of the *absolute* target path, and the directory is
|
|
45
|
+
* anchored to that target's project root. Both halves must be cwd-independent:
|
|
46
|
+
* a cwd-relative lock directory combined with an absolute key means two
|
|
47
|
+
* processes editing the same file from different working directories write to
|
|
48
|
+
* different lock files and never exclude each other.
|
|
49
|
+
*/
|
|
50
|
+
export function lockPathFor(targetFile: string): string {
|
|
51
|
+
const resolved = pathResolve(targetFile);
|
|
52
|
+
const root = findProjectRoot(dirname(resolved));
|
|
53
|
+
const key = createHash("sha256").update(resolved).digest("hex").slice(0, 32);
|
|
54
|
+
return join(root, LOCK_DIR_NAME, `${key}.lock`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Ensure the lock directory exists with safe permissions. */
|
|
58
|
+
function ensureLockDir(lockPath: string): void {
|
|
59
|
+
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o755 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Check whether a PID is alive (best-effort). */
|
|
63
|
+
function isPidAlive(pid: number): boolean {
|
|
64
|
+
try {
|
|
65
|
+
// send signal 0 — checks process existence without delivering anything.
|
|
66
|
+
process.kill(pid, 0);
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Read a lockfile back into structured data. `null` means absent or unreadable. */
|
|
74
|
+
function readLockFile(lockPath: string): LockPayload | null {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(readFileSync(lockPath, "utf8")) as LockPayload;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Age of a lock, in ms. Prefers the payload's heartbeat `ts`; falls back to the
|
|
84
|
+
* file's mtime when the payload is unreadable (a torn write, or a lockfile from
|
|
85
|
+
* an older version). Returns `null` if the file is gone.
|
|
86
|
+
*/
|
|
87
|
+
function lockAgeMs(lockPath: string, payload: LockPayload | null): number | null {
|
|
88
|
+
if (payload && typeof payload.ts === "number") return Date.now() - payload.ts;
|
|
89
|
+
try {
|
|
90
|
+
return Date.now() - statSync(lockPath).mtimeMs;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Atomically create the lockfile. Returns `true` only if *we* created it.
|
|
98
|
+
*
|
|
99
|
+
* `wx` is O_CREAT|O_EXCL: the existence check and the create are one syscall.
|
|
100
|
+
* An `existsSync` guard followed by a plain write is check-then-act — two
|
|
101
|
+
* processes can both observe no lockfile and both write, which is precisely the
|
|
102
|
+
* race this lock exists to prevent.
|
|
103
|
+
*/
|
|
104
|
+
function tryCreateLock(lockPath: string, payload: LockPayload): boolean {
|
|
105
|
+
ensureLockDir(lockPath);
|
|
106
|
+
try {
|
|
107
|
+
writeFileSync(lockPath, JSON.stringify(payload), { flag: "wx" });
|
|
108
|
+
return true;
|
|
109
|
+
} catch {
|
|
110
|
+
return false; // EEXIST (held) or an I/O error — either way we did not acquire.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Refresh the heartbeat, but only while the lockfile is still ours. */
|
|
115
|
+
function heartbeat(lockPath: string, payload: LockPayload): void {
|
|
116
|
+
const current = readLockFile(lockPath);
|
|
117
|
+
if (!current || current.nonce !== payload.nonce) return; // no longer ours
|
|
118
|
+
payload.ts = Date.now();
|
|
119
|
+
try {
|
|
120
|
+
writeFileSync(lockPath, JSON.stringify(payload));
|
|
121
|
+
} catch {
|
|
122
|
+
/* transient I/O — the next tick retries */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Release a lock we own.
|
|
128
|
+
*
|
|
129
|
+
* Unlinks only if the lockfile still carries our nonce. Unlinking by path alone
|
|
130
|
+
* is unsafe: if our lock was reclaimed as stale and another process acquired it,
|
|
131
|
+
* a blind unlink would delete *their* lockfile and hand a third writer the same
|
|
132
|
+
* file — two writers, silently.
|
|
133
|
+
*/
|
|
134
|
+
function releaseOwnedLock(lockPath: string, payload: LockPayload, timer: ReturnType<typeof setInterval>): void {
|
|
135
|
+
clearInterval(timer);
|
|
136
|
+
const current = readLockFile(lockPath);
|
|
137
|
+
if (!current || current.nonce !== payload.nonce) return; // reclaimed by someone else
|
|
138
|
+
try {
|
|
139
|
+
unlinkSync(lockPath);
|
|
140
|
+
} catch {
|
|
141
|
+
/* already gone */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Acquire an advisory lock for the given target file.
|
|
147
|
+
*
|
|
148
|
+
* Returns a release callback. Callers MUST invoke it even on error paths
|
|
149
|
+
* (e.g. in a `finally`). Extra calls are no-ops.
|
|
150
|
+
*
|
|
151
|
+
* Locks are **not** re-entrant: acquiring the same file twice in one process
|
|
152
|
+
* blocks until the timeout. Callers that already hold a lock must say so rather
|
|
153
|
+
* than nesting an acquire.
|
|
154
|
+
*/
|
|
155
|
+
export async function acquireLock(
|
|
156
|
+
targetPath: string,
|
|
157
|
+
opts?: { timeoutMs?: number },
|
|
158
|
+
): Promise<() => void> {
|
|
159
|
+
const maxWait = opts?.timeoutMs ?? LOCK_TIMEOUT_MS;
|
|
160
|
+
const lockFile = lockPathFor(targetPath);
|
|
161
|
+
const deadline = Date.now() + maxWait;
|
|
162
|
+
|
|
163
|
+
while (Date.now() < deadline) {
|
|
164
|
+
const payload: LockPayload = {
|
|
165
|
+
pid: process.pid,
|
|
166
|
+
nonce: randomBytes(12).toString("hex"),
|
|
167
|
+
ts: Date.now(),
|
|
168
|
+
targets: [targetPath],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
if (tryCreateLock(lockFile, payload)) {
|
|
172
|
+
const timer = setInterval(() => heartbeat(lockFile, payload), HEARTBEAT_MS);
|
|
173
|
+
// Never hold the event loop open just to heartbeat.
|
|
174
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
175
|
+
return once(() => releaseOwnedLock(lockFile, payload, timer));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Creation failed — someone holds it (or it is a leftover). Check staleness.
|
|
179
|
+
const existing = readLockFile(lockFile);
|
|
180
|
+
const age = lockAgeMs(lockFile, existing);
|
|
181
|
+
if (age === null) {
|
|
182
|
+
// Vanished between create and read — retry immediately-ish.
|
|
183
|
+
await sleep(LOCK_RETRY_MS);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Reclaim only when the holder stopped heartbeating AND its PID is gone.
|
|
187
|
+
// An unreadable payload has no PID to check, so age alone decides.
|
|
188
|
+
const holderGone = !existing || !isPidAlive(existing.pid);
|
|
189
|
+
if (age > STALE_THRESHOLD_MS && holderGone) {
|
|
190
|
+
try {
|
|
191
|
+
unlinkSync(lockFile);
|
|
192
|
+
} catch {
|
|
193
|
+
/* someone else raced us to the reclaim */
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Always yield. A `continue` without sleeping busy-spins a core for the
|
|
198
|
+
// whole timeout, starving the very edit we are waiting on.
|
|
199
|
+
await sleep(LOCK_RETRY_MS);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const holder = readLockFile(lockFile)?.pid ?? "?";
|
|
203
|
+
throw new LockAcquireError(
|
|
204
|
+
`Lock on ${targetPath} timed out after ${maxWait}ms (held by PID ${holder})`,
|
|
205
|
+
"timeout",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Wrap a release so extra calls are no-ops. */
|
|
210
|
+
function once(fn: () => void): () => void {
|
|
211
|
+
let done = false;
|
|
212
|
+
return () => {
|
|
213
|
+
if (done) return;
|
|
214
|
+
done = true;
|
|
215
|
+
fn();
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Sleep for `ms` milliseconds (async). */
|
|
220
|
+
function sleep(ms: number): Promise<void> {
|
|
221
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Acquire locks for multiple files in deterministic order to prevent deadlock.
|
|
226
|
+
*
|
|
227
|
+
* Deduplication is by *lock path*, not by input path: two different inputs that
|
|
228
|
+
* map to the same lockfile would otherwise make this function block against
|
|
229
|
+
* itself, since locks are not re-entrant.
|
|
230
|
+
*/
|
|
231
|
+
export async function acquireSortedLocks(
|
|
232
|
+
paths: string[],
|
|
233
|
+
opts?: { timeoutMs?: number },
|
|
234
|
+
): Promise<() => void> {
|
|
235
|
+
const byLockPath = new Map<string, string>();
|
|
236
|
+
for (const p of paths) {
|
|
237
|
+
const lp = lockPathFor(p);
|
|
238
|
+
if (!byLockPath.has(lp)) byLockPath.set(lp, p);
|
|
239
|
+
}
|
|
240
|
+
const sorted = [...byLockPath.keys()].sort((a, b) => a.localeCompare(b));
|
|
241
|
+
const releases: (() => void)[] = [];
|
|
242
|
+
|
|
243
|
+
for (const lp of sorted) {
|
|
244
|
+
try {
|
|
245
|
+
releases.push(await acquireLock(byLockPath.get(lp)!, opts));
|
|
246
|
+
} catch (err) {
|
|
247
|
+
// Release everything we already acquired on failure.
|
|
248
|
+
for (const rel of releases) {
|
|
249
|
+
try { rel(); } catch { /* ignore */ }
|
|
250
|
+
}
|
|
251
|
+
throw err;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return once(() => {
|
|
256
|
+
// Release in reverse order (LIFO).
|
|
257
|
+
for (let i = releases.length - 1; i >= 0; i--) {
|
|
258
|
+
try { releases[i](); } catch { /* ignore */ }
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Remove reclaimable lockfiles left behind by crashed processes.
|
|
265
|
+
* Returns the number of lockfiles removed. Safe to call at startup.
|
|
266
|
+
*/
|
|
267
|
+
export function pruneStaleLocks(root: string = findProjectRoot()): number {
|
|
268
|
+
const dir = join(root, LOCK_DIR_NAME);
|
|
269
|
+
if (!existsSync(dir)) return 0;
|
|
270
|
+
let removed = 0;
|
|
271
|
+
let entries: string[];
|
|
272
|
+
try {
|
|
273
|
+
entries = readdirSync(dir);
|
|
274
|
+
} catch {
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
for (const name of entries) {
|
|
278
|
+
if (!name.endsWith(".lock")) continue;
|
|
279
|
+
const lockPath = join(dir, name);
|
|
280
|
+
const payload = readLockFile(lockPath);
|
|
281
|
+
const age = lockAgeMs(lockPath, payload);
|
|
282
|
+
if (age === null) continue;
|
|
283
|
+
const holderGone = !payload || !isPidAlive(payload.pid);
|
|
284
|
+
if (age > STALE_THRESHOLD_MS && holderGone) {
|
|
285
|
+
try {
|
|
286
|
+
unlinkSync(lockPath);
|
|
287
|
+
removed++;
|
|
288
|
+
} catch { /* ignore */ }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return removed;
|
|
292
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JavaScript module-system detection (#139).
|
|
3
|
+
*
|
|
4
|
+
* `add-import` used to emit ESM `import` syntax into every JavaScript file,
|
|
5
|
+
* report `success: true`, and leave a CommonJS file that Node refuses to load.
|
|
6
|
+
* The parse-validity gate cannot catch it: tree-sitter's JavaScript grammar
|
|
7
|
+
* accepts `import` and `require` in the same file, so the result parses. Node
|
|
8
|
+
* does not.
|
|
9
|
+
*
|
|
10
|
+
* This module answers "which module system does this file use?" from the file
|
|
11
|
+
* path plus its content, so the caller can emit the right syntax or refuse.
|
|
12
|
+
* It is deliberately free of tree-sitter: the signals are the extension, the
|
|
13
|
+
* nearest `package.json`, and a content sniff, none of which need a parse.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { dirname, join, parse as parsePath, resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
export type ModuleSystem = "esm" | "cjs";
|
|
19
|
+
|
|
20
|
+
/** How the verdict was reached — carried into error messages so a refusal explains itself. */
|
|
21
|
+
export type ModuleSystemSignal = "extension" | "package.json" | "content" | "default";
|
|
22
|
+
|
|
23
|
+
export interface ModuleSystemVerdict {
|
|
24
|
+
/** `null` when the signals contradict each other and no default is safe. */
|
|
25
|
+
system: ModuleSystem | null;
|
|
26
|
+
signal: ModuleSystemSignal;
|
|
27
|
+
/** Human-readable reason, suitable for an error message. */
|
|
28
|
+
detail: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Walk up from `startDir` looking for the nearest `package.json`, and report its
|
|
33
|
+
* `type` field. Returns `null` when no `package.json` exists anywhere above the
|
|
34
|
+
* file — a bare script outside any package, where the field cannot speak.
|
|
35
|
+
*
|
|
36
|
+
* A `package.json` that exists but declares no `type` is not silence: the field
|
|
37
|
+
* defaults to `"commonjs"` per Node's own resolution rules, so that is a real
|
|
38
|
+
* CJS signal.
|
|
39
|
+
*
|
|
40
|
+
* `startDir` is absolutized against `process.cwd()` before the walk begins
|
|
41
|
+
* (#161): `path.parse()` reports `root: ""` for a relative path, which would
|
|
42
|
+
* otherwise never equal `dir` and let the walk run past the real filesystem
|
|
43
|
+
* root — silently stopping at `cwd` instead of reaching a monorepo root
|
|
44
|
+
* `package.json` that sits above it.
|
|
45
|
+
*/
|
|
46
|
+
export function nearestPackageType(startDir: string): { system: ModuleSystem; path: string } | null {
|
|
47
|
+
let dir = resolve(startDir);
|
|
48
|
+
const { root } = parsePath(dir);
|
|
49
|
+
// Bounded by the filesystem root; `dirname("/") === "/"` terminates the walk.
|
|
50
|
+
for (;;) {
|
|
51
|
+
const candidate = join(dir, "package.json");
|
|
52
|
+
let raw: string;
|
|
53
|
+
try {
|
|
54
|
+
raw = readFileSync(candidate, "utf8");
|
|
55
|
+
} catch {
|
|
56
|
+
if (dir === root) return null;
|
|
57
|
+
const parent = dirname(dir);
|
|
58
|
+
if (parent === dir) return null;
|
|
59
|
+
dir = parent;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
let type: unknown;
|
|
63
|
+
try {
|
|
64
|
+
type = (JSON.parse(raw) as { type?: unknown }).type;
|
|
65
|
+
} catch {
|
|
66
|
+
// A malformed package.json is not a signal. Keep walking rather than
|
|
67
|
+
// guessing from a file we could not read.
|
|
68
|
+
if (dir === root) return null;
|
|
69
|
+
const parent = dirname(dir);
|
|
70
|
+
if (parent === dir) return null;
|
|
71
|
+
dir = parent;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
return { system: type === "module" ? "esm" : "cjs", path: candidate };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** True when the source contains a CommonJS marker outside of an obvious comment. */
|
|
79
|
+
function hasCjsMarkers(source: string): boolean {
|
|
80
|
+
return /(^|[^.\w$])require\s*\(/.test(source) || /\bmodule\.exports\b/.test(source) || /\bexports\.\w/.test(source);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** True when the source contains an ESM marker at the start of some line. */
|
|
84
|
+
function hasEsmMarkers(source: string): boolean {
|
|
85
|
+
return /^\s*import\s+[^(]/m.test(source) || /^\s*import\s*[{*]/m.test(source) || /^\s*export\s/m.test(source);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decide a JavaScript file's module system. Signals, cheapest first — first
|
|
90
|
+
* match wins:
|
|
91
|
+
*
|
|
92
|
+
* 1. Extension: `.cjs` is always CommonJS, `.mjs` always ESM. Node ignores
|
|
93
|
+
* `package.json` for both, so nothing below can overrule them.
|
|
94
|
+
* 2. Nearest `package.json` `type` field (absent field ⇒ CommonJS).
|
|
95
|
+
* 3. Content: `require(` / `module.exports` / `exports.x` ⇒ CJS, a top-level
|
|
96
|
+
* `import`/`export` ⇒ ESM. **Both** ⇒ no verdict; the caller must refuse
|
|
97
|
+
* rather than pick.
|
|
98
|
+
*
|
|
99
|
+
* With no signal at all — a bare script outside any package, holding neither
|
|
100
|
+
* marker — the verdict is ESM. That is the historical behavior and the modern
|
|
101
|
+
* default; it is reported as `signal: "default"` so a caller can tell a guess
|
|
102
|
+
* from a finding.
|
|
103
|
+
*
|
|
104
|
+
* Only meaningful for JavaScript. TypeScript compiles to whichever system its
|
|
105
|
+
* own config selects, so callers must not consult this for `.ts`/`.tsx`.
|
|
106
|
+
*/
|
|
107
|
+
export function detectModuleSystem(filePath: string, source: string): ModuleSystemVerdict {
|
|
108
|
+
if (filePath.endsWith(".cjs")) {
|
|
109
|
+
return { system: "cjs", signal: "extension", detail: "the .cjs extension is always CommonJS" };
|
|
110
|
+
}
|
|
111
|
+
if (filePath.endsWith(".mjs")) {
|
|
112
|
+
return { system: "esm", signal: "extension", detail: "the .mjs extension is always ESM" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const pkg = nearestPackageType(dirname(filePath));
|
|
116
|
+
if (pkg) {
|
|
117
|
+
return {
|
|
118
|
+
system: pkg.system,
|
|
119
|
+
signal: "package.json",
|
|
120
|
+
detail:
|
|
121
|
+
pkg.system === "esm"
|
|
122
|
+
? `${pkg.path} declares "type": "module"`
|
|
123
|
+
: `${pkg.path} does not declare "type": "module", so Node treats this file as CommonJS`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cjs = hasCjsMarkers(source);
|
|
128
|
+
const esm = hasEsmMarkers(source);
|
|
129
|
+
if (cjs && esm) {
|
|
130
|
+
return {
|
|
131
|
+
system: null,
|
|
132
|
+
signal: "content",
|
|
133
|
+
detail:
|
|
134
|
+
"the file mixes CommonJS (require/module.exports) and ESM (import/export) syntax, " +
|
|
135
|
+
"and no extension or package.json settles which one Node will use",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (cjs) return { system: "cjs", signal: "content", detail: "the file already uses require/module.exports" };
|
|
139
|
+
if (esm) return { system: "esm", signal: "content", detail: "the file already uses import/export" };
|
|
140
|
+
|
|
141
|
+
return { system: "esm", signal: "default", detail: "no extension, package.json, or in-file signal; defaulting to ESM" };
|
|
142
|
+
}
|