@ai-ecoverse/biome-jsh 0.0.0-development
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/README.md +83 -0
- package/biome-jsh.mjs +342 -0
- package/jsh-biome-source.mjs +112 -0
- package/lib.mjs +134 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# biome-jsh — a jsh-aware Biome runner
|
|
2
|
+
|
|
3
|
+
Biome's CLI ignores `.jsh` / `.bsh` files, so linting them means renaming each
|
|
4
|
+
one to `.js` first. But SLICC shell scripts run as an **AsyncFunction body**
|
|
5
|
+
(see `kernel/realm/realm-module-system.ts`): top-level `await` **and** top-level
|
|
6
|
+
`return` are both valid. A naive rename makes Biome parse the body as a module
|
|
7
|
+
and emit a bogus error:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
× Illegal return statement outside of a function
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`biome-jsh` fixes this. For every `.jsh` / `.bsh` file it:
|
|
14
|
+
|
|
15
|
+
1. wraps the body in `async function __slicc() { … }` (the same shape the
|
|
16
|
+
runtime uses), so top-level `await`/`return` parse cleanly;
|
|
17
|
+
2. writes the wrapped content to a temp `.js` file and runs Biome on it in
|
|
18
|
+
**file mode** with `--reporter=github`;
|
|
19
|
+
3. shifts every diagnostic back onto the real file — the wrapper prefix is one
|
|
20
|
+
newline-terminated line at column 0, so only the line number moves (columns
|
|
21
|
+
are already correct) — and rewrites the temp path to the real `.jsh` path.
|
|
22
|
+
|
|
23
|
+
`.js` / `.ts` / `.json` / … files pass straight through, unwrapped.
|
|
24
|
+
|
|
25
|
+
This is the single jsh-aware runner meant to replace the ad-hoc
|
|
26
|
+
"copy-to-`.js`, lint, rename back" hack in downstream CI (e.g.
|
|
27
|
+
`ai-ecoverse/skills`). The wrap/unwrap/span-shift logic in
|
|
28
|
+
[`jsh-biome-source.mjs`](./jsh-biome-source.mjs) is a byte-aligned mirror of the
|
|
29
|
+
in-app Biome command
|
|
30
|
+
(`packages/webapp/src/shell/supplemental-commands/jsh-biome-source.ts`, WASM
|
|
31
|
+
path); this CLI is the binary path.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
biome-jsh check [paths...] # lint + format-check (github reporter)
|
|
37
|
+
biome-jsh format [paths...] # print formatted output to stdout
|
|
38
|
+
biome-jsh format --write [paths...] # format files in place
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Paths may be files or directories (walked recursively; `node_modules` and
|
|
42
|
+
`.git` skipped). `check` exits non-zero when any file has an error or is not
|
|
43
|
+
formatted, and emits GitHub Actions annotations on stdout so CI surfaces them
|
|
44
|
+
inline.
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
biome-jsh check skills/
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Biome binary
|
|
51
|
+
|
|
52
|
+
`@biomejs/biome` is a declared dependency but the binary is **resolved at
|
|
53
|
+
runtime**, not bundled: `biome-jsh` looks for `node_modules/.bin/biome` walking
|
|
54
|
+
up from the current directory and from its own location, or uses `$BIOME_BIN`.
|
|
55
|
+
Any already-installed Biome is reused, so no fresh install is required.
|
|
56
|
+
|
|
57
|
+
## Published package
|
|
58
|
+
|
|
59
|
+
Published to npm as **`@ai-ecoverse/biome-jsh`** (public), released **in lockstep
|
|
60
|
+
with SLICC** via slicc's semantic-release pipeline — its version tracks the
|
|
61
|
+
`sliccy` release version (see the second `@semantic-release/npm` target with
|
|
62
|
+
`pkgRoot: packages/dev-tools/biome-jsh` in `.releaserc.json`). Downstream repos
|
|
63
|
+
consume it as a dev dependency:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npm i -D @ai-ecoverse/biome-jsh
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
then run `npx biome-jsh check <paths>` in CI — it reuses the repo's own
|
|
70
|
+
already-installed `@biomejs/biome`, so there's nothing to rename and no false
|
|
71
|
+
`return`/`await` parse errors.
|
|
72
|
+
|
|
73
|
+
## Layout
|
|
74
|
+
|
|
75
|
+
| File | Role |
|
|
76
|
+
| ---------------------- | --------------------------------------------------------------- |
|
|
77
|
+
| `biome-jsh.mjs` | CLI entry (I/O: file walking, temp files, spawning Biome). |
|
|
78
|
+
| `lib.mjs` | Pure logic: github-annotation parse / shift / rewrite. |
|
|
79
|
+
| `jsh-biome-source.mjs` | Pure wrap / unwrap / span-shift helpers (mirror of the webapp). |
|
|
80
|
+
|
|
81
|
+
Tests are co-located `*.test.mjs` and run under the repo's `dev-tools` Vitest
|
|
82
|
+
project. The integration suite spawns the real Biome binary and skips cleanly
|
|
83
|
+
when none is installed.
|
package/biome-jsh.mjs
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* biome-jsh — a jsh-aware Biome runner.
|
|
4
|
+
*
|
|
5
|
+
* Biome's CLI ignores `.jsh`/`.bsh` files, and these scripts run as an
|
|
6
|
+
* AsyncFunction body (top-level `await` AND `return` are valid). Renaming a
|
|
7
|
+
* `.jsh` to `.js` and linting it (the naive hack) makes Biome parse the body
|
|
8
|
+
* as a module and emit a bogus "Illegal return statement outside of a
|
|
9
|
+
* function" error. This tool instead wraps each `.jsh`/`.bsh` body in an async
|
|
10
|
+
* function, runs Biome (`--reporter=github`) on the wrapped temp `.js`, then
|
|
11
|
+
* shifts every diagnostic back onto the real file — so top-level await/return
|
|
12
|
+
* never trip a false positive. `.js/.ts/.json/...` pass straight through.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* biome-jsh check [paths...] Lint + format-check (github reporter)
|
|
16
|
+
* biome-jsh format [paths...] Print formatted output to stdout
|
|
17
|
+
* biome-jsh format --write [paths...] Format in place
|
|
18
|
+
*
|
|
19
|
+
* The `@biomejs/biome` binary is resolved at runtime from `node_modules/.bin`
|
|
20
|
+
* (walking up from CWD and from this file), or from $BIOME_BIN — it is NOT
|
|
21
|
+
* bundled, so an existing install is reused rather than a fresh one required.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import {
|
|
26
|
+
existsSync,
|
|
27
|
+
readdirSync,
|
|
28
|
+
readFileSync,
|
|
29
|
+
statSync,
|
|
30
|
+
unlinkSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
} from 'node:fs';
|
|
33
|
+
import { basename, dirname, join } from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import {
|
|
36
|
+
isLintableFile,
|
|
37
|
+
JSH_WRAP_PREFIX_LINE_COUNT,
|
|
38
|
+
shouldWrapForBiome,
|
|
39
|
+
unwrapFormattedJsh,
|
|
40
|
+
wrapJshForBiome,
|
|
41
|
+
} from './jsh-biome-source.mjs';
|
|
42
|
+
import {
|
|
43
|
+
biomeBinCandidates,
|
|
44
|
+
formatGithubAnnotation,
|
|
45
|
+
makeErrorAnnotation,
|
|
46
|
+
remapGithubOutput,
|
|
47
|
+
} from './lib.mjs';
|
|
48
|
+
|
|
49
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
50
|
+
|
|
51
|
+
const HELP = `biome-jsh - a jsh-aware Biome runner
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
biome-jsh check [paths...] Lint + format-check (--reporter=github)
|
|
55
|
+
biome-jsh format [paths...] Print formatted output to stdout
|
|
56
|
+
biome-jsh format --write [paths...] Format files in place
|
|
57
|
+
|
|
58
|
+
.jsh/.bsh bodies are wrapped in an async function before Biome sees them, so
|
|
59
|
+
top-level await/return do not raise a false "return outside of function" error.
|
|
60
|
+
Diagnostics are shifted back onto the real file. Other extensions pass through.
|
|
61
|
+
|
|
62
|
+
Env:
|
|
63
|
+
BIOME_BIN Path to the @biomejs/biome binary (default: resolved from
|
|
64
|
+
node_modules/.bin, walking up from CWD and this tool).
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
function resolveBiomeBinary() {
|
|
68
|
+
const fromEnv = process.env.BIOME_BIN;
|
|
69
|
+
if (fromEnv) {
|
|
70
|
+
if (existsSync(fromEnv)) return fromEnv;
|
|
71
|
+
fail(`BIOME_BIN is set to '${fromEnv}' but that file does not exist`);
|
|
72
|
+
}
|
|
73
|
+
for (const candidate of biomeBinCandidates([process.cwd(), HERE])) {
|
|
74
|
+
if (existsSync(candidate)) return candidate;
|
|
75
|
+
}
|
|
76
|
+
fail(
|
|
77
|
+
'could not find the @biomejs/biome binary. Install it (npm i @biomejs/biome) ' +
|
|
78
|
+
'or set BIOME_BIN to its path.'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function runBiome(bin, args, cwd) {
|
|
83
|
+
const result = spawnSync(bin, args, {
|
|
84
|
+
cwd,
|
|
85
|
+
encoding: 'utf8',
|
|
86
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
87
|
+
});
|
|
88
|
+
if (result.error) fail(`failed to run biome: ${result.error.message}`);
|
|
89
|
+
return {
|
|
90
|
+
stdout: result.stdout ?? '',
|
|
91
|
+
stderr: result.stderr ?? '',
|
|
92
|
+
status: result.status ?? 0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseArgs(argv) {
|
|
97
|
+
const parsed = { subcommand: null, paths: [], write: false, help: false, version: false };
|
|
98
|
+
for (const arg of argv) {
|
|
99
|
+
if (arg === '-h' || arg === '--help') parsed.help = true;
|
|
100
|
+
else if (arg === '-v' || arg === '--version') parsed.version = true;
|
|
101
|
+
else if (arg === '--write') parsed.write = true;
|
|
102
|
+
else if (parsed.subcommand === null && (arg === 'check' || arg === 'format'))
|
|
103
|
+
parsed.subcommand = arg;
|
|
104
|
+
else if (arg.startsWith('-')) fail(`unknown option: ${arg}`);
|
|
105
|
+
else parsed.paths.push(arg);
|
|
106
|
+
}
|
|
107
|
+
return parsed;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function expandPaths(paths) {
|
|
111
|
+
const files = [];
|
|
112
|
+
const missing = [];
|
|
113
|
+
for (const raw of paths) {
|
|
114
|
+
if (!existsSync(raw)) {
|
|
115
|
+
missing.push(raw);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (statSync(raw).isDirectory()) walkDirectory(raw, files);
|
|
119
|
+
else if (isLintableFile(raw)) files.push(raw);
|
|
120
|
+
}
|
|
121
|
+
return { files, missing };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function walkDirectory(dir, out) {
|
|
125
|
+
for (const name of readdirSync(dir)) {
|
|
126
|
+
if (name === 'node_modules' || name.startsWith('.git')) continue;
|
|
127
|
+
const full = join(dir, name);
|
|
128
|
+
if (statSync(full).isDirectory()) walkDirectory(full, out);
|
|
129
|
+
else if (isLintableFile(full)) out.push(full);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Format the wrapped content of a `.jsh`/`.bsh` file back to real-file terms.
|
|
134
|
+
* Biome 2.x `format` (no --write) only prints a diff, so we format the temp in
|
|
135
|
+
* place with `--write` and read it back. Mirrors the webapp WASM path: a
|
|
136
|
+
* re-format round-trip guards the tab de-indent so a lossy unwrap (multi-line
|
|
137
|
+
* template literals whose lines start with a real tab) keeps the source. */
|
|
138
|
+
function formatWrapped(bin, source, dir, tempBase, tempPath) {
|
|
139
|
+
const wrapped = wrapJshForBiome(source);
|
|
140
|
+
writeFileSync(tempPath, wrapped);
|
|
141
|
+
const fmt = runBiome(bin, ['format', '--write', tempBase], dir);
|
|
142
|
+
// A non-zero status means Biome could not format (parse error, bad config);
|
|
143
|
+
// the temp is left unchanged, so surface it rather than reporting success.
|
|
144
|
+
if (fmt.status !== 0) return { safe: source, changed: false, failed: true, stderr: fmt.stderr };
|
|
145
|
+
const formattedWrapped = readFileSync(tempPath, 'utf8');
|
|
146
|
+
// Unchanged (already formatted) → no diff.
|
|
147
|
+
if (formattedWrapped === wrapped) return { safe: source, changed: false, failed: false };
|
|
148
|
+
const candidate = unwrapFormattedJsh(formattedWrapped);
|
|
149
|
+
writeFileSync(tempPath, wrapJshForBiome(candidate));
|
|
150
|
+
const reFmt = runBiome(bin, ['format', '--write', tempBase], dir);
|
|
151
|
+
if (reFmt.status !== 0)
|
|
152
|
+
return { safe: source, changed: false, failed: true, stderr: reFmt.stderr };
|
|
153
|
+
const reFormatted = readFileSync(tempPath, 'utf8');
|
|
154
|
+
const safe = reFormatted === formattedWrapped ? candidate : source;
|
|
155
|
+
return { safe, changed: safe !== source, failed: false };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function processWrappedCheck(bin, file) {
|
|
159
|
+
const dir = dirname(file);
|
|
160
|
+
const source = readFileSync(file, 'utf8');
|
|
161
|
+
const tempBase = tempName(file);
|
|
162
|
+
const tempPath = join(dir, tempBase);
|
|
163
|
+
const out = { lines: [], errorCount: 0, warningCount: 0 };
|
|
164
|
+
try {
|
|
165
|
+
writeFileSync(tempPath, wrapJshForBiome(source));
|
|
166
|
+
const lint = runBiome(bin, ['lint', '--reporter=github', tempBase], dir);
|
|
167
|
+
const remapped = remapGithubOutput(lint.stdout, file, JSH_WRAP_PREFIX_LINE_COUNT);
|
|
168
|
+
out.lines.push(...remapped.lines);
|
|
169
|
+
out.errorCount += remapped.errorCount;
|
|
170
|
+
out.warningCount += remapped.warningCount;
|
|
171
|
+
// A hard biome failure (e.g. bad config) exits non-zero with no annotations.
|
|
172
|
+
if (lint.status !== 0 && remapped.errorCount === 0 && remapped.warningCount === 0) {
|
|
173
|
+
out.errorCount++;
|
|
174
|
+
out.lines.push(makeStderrLines(lint.stderr));
|
|
175
|
+
}
|
|
176
|
+
const fmt = formatWrapped(bin, source, dir, tempBase, tempPath);
|
|
177
|
+
if (fmt.failed) {
|
|
178
|
+
out.lines.push(
|
|
179
|
+
formatGithubAnnotation(makeErrorAnnotation(file, 'Biome could not format this file'))
|
|
180
|
+
);
|
|
181
|
+
out.lines.push(makeStderrLines(fmt.stderr));
|
|
182
|
+
out.errorCount++;
|
|
183
|
+
} else if (fmt.safe !== source) {
|
|
184
|
+
out.lines.push(
|
|
185
|
+
formatGithubAnnotation(
|
|
186
|
+
makeErrorAnnotation(file, 'File is not formatted (run: biome-jsh format --write)')
|
|
187
|
+
)
|
|
188
|
+
);
|
|
189
|
+
out.errorCount++;
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
safeUnlink(tempPath);
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function processWrappedFormat(bin, file, write) {
|
|
198
|
+
const dir = dirname(file);
|
|
199
|
+
const source = readFileSync(file, 'utf8');
|
|
200
|
+
const tempBase = tempName(file);
|
|
201
|
+
const tempPath = join(dir, tempBase);
|
|
202
|
+
try {
|
|
203
|
+
const { safe, changed, failed, stderr } = formatWrapped(bin, source, dir, tempBase, tempPath);
|
|
204
|
+
if (failed) {
|
|
205
|
+
process.stderr.write(`biome-jsh: ${file}: ${makeStderrLines(stderr)}\n`);
|
|
206
|
+
return { stdout: '', changed: false, failed: true };
|
|
207
|
+
}
|
|
208
|
+
if (write) {
|
|
209
|
+
if (changed) writeFileSync(file, safe);
|
|
210
|
+
return { stdout: '', changed, failed: false };
|
|
211
|
+
}
|
|
212
|
+
return { stdout: safe, changed, failed: false };
|
|
213
|
+
} finally {
|
|
214
|
+
safeUnlink(tempPath);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function processPlainCheck(bin, file) {
|
|
219
|
+
const dir = dirname(file);
|
|
220
|
+
const result = runBiome(bin, ['check', '--reporter=github', basename(file)], dir);
|
|
221
|
+
const remapped = remapGithubOutput(result.stdout, file, 0);
|
|
222
|
+
if (result.status !== 0 && remapped.errorCount === 0 && remapped.warningCount === 0) {
|
|
223
|
+
remapped.errorCount++;
|
|
224
|
+
remapped.lines.push(makeStderrLines(result.stderr));
|
|
225
|
+
}
|
|
226
|
+
return remapped;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function processPlainFormat(bin, file, write) {
|
|
230
|
+
const dir = dirname(file);
|
|
231
|
+
if (write) {
|
|
232
|
+
const r = runBiome(bin, ['format', '--write', basename(file)], dir);
|
|
233
|
+
if (r.status !== 0) {
|
|
234
|
+
process.stderr.write(`biome-jsh: ${file}: ${makeStderrLines(r.stderr)}\n`);
|
|
235
|
+
return { stdout: '', failed: true };
|
|
236
|
+
}
|
|
237
|
+
return { stdout: '' };
|
|
238
|
+
}
|
|
239
|
+
// Biome 2.x `format` (no --write) prints a diff, not the formatted content,
|
|
240
|
+
// so format a same-extension temp copy in place and read it back.
|
|
241
|
+
const source = readFileSync(file, 'utf8');
|
|
242
|
+
const dot = basename(file).lastIndexOf('.');
|
|
243
|
+
const ext = dot >= 0 ? basename(file).slice(dot) : '';
|
|
244
|
+
const tempBase = `${basename(file)}.__biomejsh__.${mkToken()}${ext}`;
|
|
245
|
+
const tempPath = join(dir, tempBase);
|
|
246
|
+
try {
|
|
247
|
+
writeFileSync(tempPath, source);
|
|
248
|
+
const r = runBiome(bin, ['format', '--write', tempBase], dir);
|
|
249
|
+
if (r.status !== 0) {
|
|
250
|
+
process.stderr.write(`biome-jsh: ${file}: ${makeStderrLines(r.stderr)}\n`);
|
|
251
|
+
return { stdout: '', failed: true };
|
|
252
|
+
}
|
|
253
|
+
return { stdout: readFileSync(tempPath, 'utf8') };
|
|
254
|
+
} finally {
|
|
255
|
+
safeUnlink(tempPath);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function tempName(file) {
|
|
260
|
+
const token = mkToken();
|
|
261
|
+
return `${basename(file)}.__biomejsh__.${token}.js`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let tokenCounter = 0;
|
|
265
|
+
function mkToken() {
|
|
266
|
+
tokenCounter += 1;
|
|
267
|
+
return `${process.pid}-${Date.now()}-${tokenCounter}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function makeStderrLines(stderr) {
|
|
271
|
+
return stderr.trim() === '' ? 'biome exited non-zero' : stderr.trimEnd();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function safeUnlink(path) {
|
|
275
|
+
try {
|
|
276
|
+
unlinkSync(path);
|
|
277
|
+
} catch {
|
|
278
|
+
// best-effort cleanup
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function fail(message) {
|
|
283
|
+
process.stderr.write(`biome-jsh: ${message}\n`);
|
|
284
|
+
process.exit(2);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function runCheck(bin, files, missing) {
|
|
288
|
+
const outLines = [];
|
|
289
|
+
let errorCount = missing.length;
|
|
290
|
+
let warningCount = 0;
|
|
291
|
+
for (const file of files) {
|
|
292
|
+
const r = shouldWrapForBiome(file)
|
|
293
|
+
? processWrappedCheck(bin, file)
|
|
294
|
+
: processPlainCheck(bin, file);
|
|
295
|
+
outLines.push(...r.lines);
|
|
296
|
+
errorCount += r.errorCount;
|
|
297
|
+
warningCount += r.warningCount;
|
|
298
|
+
}
|
|
299
|
+
if (outLines.length > 0) process.stdout.write(outLines.join('\n') + '\n');
|
|
300
|
+
process.stderr.write(
|
|
301
|
+
`biome-jsh: ${files.length} file(s), ${errorCount} error(s), ${warningCount} warning(s)\n`
|
|
302
|
+
);
|
|
303
|
+
process.exitCode = errorCount > 0 ? 1 : 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function runFormat(bin, files, write, hadMissing) {
|
|
307
|
+
const stdoutChunks = [];
|
|
308
|
+
let failed = 0;
|
|
309
|
+
for (const file of files) {
|
|
310
|
+
const r = shouldWrapForBiome(file)
|
|
311
|
+
? processWrappedFormat(bin, file, write)
|
|
312
|
+
: processPlainFormat(bin, file, write);
|
|
313
|
+
if (r.failed) failed += 1;
|
|
314
|
+
else if (!write && r.stdout) stdoutChunks.push(r.stdout);
|
|
315
|
+
}
|
|
316
|
+
if (!write && stdoutChunks.length > 0) process.stdout.write(stdoutChunks.join(''));
|
|
317
|
+
process.exitCode = hadMissing || failed > 0 ? 1 : 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function main() {
|
|
321
|
+
const parsed = parseArgs(process.argv.slice(2));
|
|
322
|
+
if (parsed.help || (!parsed.subcommand && parsed.paths.length === 0 && !parsed.version)) {
|
|
323
|
+
process.stdout.write(HELP);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const bin = resolveBiomeBinary();
|
|
327
|
+
if (parsed.version) {
|
|
328
|
+
const v = runBiome(bin, ['--version'], process.cwd());
|
|
329
|
+
process.stdout.write(v.stdout || v.stderr);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!parsed.subcommand) fail('missing subcommand (expected check or format)');
|
|
333
|
+
if (parsed.paths.length === 0) fail('no files or directories specified');
|
|
334
|
+
|
|
335
|
+
const { files, missing } = expandPaths(parsed.paths);
|
|
336
|
+
for (const m of missing) process.stderr.write(`biome-jsh: ${m}: no such file or directory\n`);
|
|
337
|
+
|
|
338
|
+
if (parsed.subcommand === 'check') runCheck(bin, files, missing);
|
|
339
|
+
else runFormat(bin, files, parsed.write, missing.length > 0);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
main();
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, dependency-free helpers that make Biome lint/format `.jsh`/`.bsh`
|
|
3
|
+
* shell scripts correctly. Self-contained so the `biome-jsh` CLI stays
|
|
4
|
+
* standalone and publishable without a `@slicc/*` dependency.
|
|
5
|
+
*
|
|
6
|
+
* `.jsh` scripts and `.bsh` browser helpers run as an AsyncFunction body, so
|
|
7
|
+
* top-level `await` AND top-level `return` are valid there. Biome's file-mode
|
|
8
|
+
* parser maps a bare `.jsh`/`.bsh` body to a module and emits a bogus "return
|
|
9
|
+
* outside of function" parse error, so the body is wrapped in an async
|
|
10
|
+
* function before Biome sees it and the diagnostics are shifted back.
|
|
11
|
+
*
|
|
12
|
+
* This is a byte-aligned mirror of
|
|
13
|
+
* `packages/webapp/src/shell/supplemental-commands/jsh-biome-source.ts`
|
|
14
|
+
* (the in-app WASM path). Keep the two in sync when either changes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const LINTABLE_EXTENSIONS = new Set([
|
|
18
|
+
'js',
|
|
19
|
+
'mjs',
|
|
20
|
+
'cjs',
|
|
21
|
+
'jsx',
|
|
22
|
+
'ts',
|
|
23
|
+
'mts',
|
|
24
|
+
'cts',
|
|
25
|
+
'tsx',
|
|
26
|
+
'json',
|
|
27
|
+
'jsonc',
|
|
28
|
+
'css',
|
|
29
|
+
'graphql',
|
|
30
|
+
'gql',
|
|
31
|
+
'html',
|
|
32
|
+
'svelte',
|
|
33
|
+
'vue',
|
|
34
|
+
'astro',
|
|
35
|
+
'jsh',
|
|
36
|
+
'bsh',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/** True when `path` has an extension Biome (via this wrapper) can lint. */
|
|
40
|
+
export function isLintableFile(path) {
|
|
41
|
+
const dot = path.lastIndexOf('.');
|
|
42
|
+
if (dot < 0) return false;
|
|
43
|
+
return LINTABLE_EXTENSIONS.has(path.slice(dot + 1).toLowerCase());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Map a real path to the virtual path Biome should parse it under. `.jsh`
|
|
48
|
+
* and `.bsh` both wrap to an AsyncFunction body, so a plain `.js` parser
|
|
49
|
+
* path is correct for both. Everything else is returned unchanged.
|
|
50
|
+
*/
|
|
51
|
+
export function biomeVirtualPath(realPath) {
|
|
52
|
+
if (realPath.endsWith('.jsh')) return `${realPath.slice(0, -'.jsh'.length)}.js`;
|
|
53
|
+
if (realPath.endsWith('.bsh')) return `${realPath.slice(0, -'.bsh'.length)}.js`;
|
|
54
|
+
return realPath;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wrapper that gives a `.jsh`/`.bsh` body the same AsyncFunction semantics it
|
|
59
|
+
* runs under, so top-level `await` and top-level `return` are both valid and
|
|
60
|
+
* Biome does not emit a bogus "return outside of function" parse error.
|
|
61
|
+
*
|
|
62
|
+
* The body is placed at column 0 on its own lines — the prefix ends in a
|
|
63
|
+
* newline and adds no indentation — so a diagnostic's column is identical to
|
|
64
|
+
* the real file's column and only its line/byte offset shifts by the prefix.
|
|
65
|
+
*/
|
|
66
|
+
export const JSH_WRAP_PREFIX = 'async function __slicc() {\n';
|
|
67
|
+
export const JSH_WRAP_SUFFIX = '\n}';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* UTF-8 byte length of {@link JSH_WRAP_PREFIX}. Biome diagnostic spans are
|
|
71
|
+
* byte offsets, so a wrapped-source span maps back by subtracting this.
|
|
72
|
+
*/
|
|
73
|
+
export const JSH_WRAP_PREFIX_BYTE_LENGTH = new TextEncoder().encode(JSH_WRAP_PREFIX).length;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Number of newlines the prefix inserts ahead of the body. The `--reporter=github`
|
|
77
|
+
* output is line/column based, so a diagnostic on the wrapped source maps back
|
|
78
|
+
* to the real file by subtracting this from its `line`/`endLine`. Because the
|
|
79
|
+
* prefix is exactly one newline-terminated line at column 0, this is the
|
|
80
|
+
* line-space equivalent of subtracting {@link JSH_WRAP_PREFIX_BYTE_LENGTH}.
|
|
81
|
+
*/
|
|
82
|
+
export const JSH_WRAP_PREFIX_LINE_COUNT = (JSH_WRAP_PREFIX.match(/\n/g) || []).length;
|
|
83
|
+
|
|
84
|
+
/** Wrap a `.jsh`/`.bsh` body for Biome. Inverse: {@link unwrapFormattedJsh}. */
|
|
85
|
+
export function wrapJshForBiome(source) {
|
|
86
|
+
return JSH_WRAP_PREFIX + source + JSH_WRAP_SUFFIX;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when `realPath` is a shell script whose body needs the wrapper. */
|
|
90
|
+
export function shouldWrapForBiome(realPath) {
|
|
91
|
+
return realPath.endsWith('.jsh') || realPath.endsWith('.bsh');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Strip the async-function wrapper Biome added around a formatted
|
|
96
|
+
* `.jsh`/`.bsh` body: drop the first line (`async function __slicc() {`) and
|
|
97
|
+
* the last line (`}`), then remove exactly one leading TAB (Biome's default
|
|
98
|
+
* indent unit) from each remaining line. A trailing newline is always emitted.
|
|
99
|
+
*
|
|
100
|
+
* This de-indent is NOT lossless for multi-line template literals whose
|
|
101
|
+
* continuation lines start with a real tab — callers MUST guard the result
|
|
102
|
+
* with a re-format round-trip and fall back to the original content when it
|
|
103
|
+
* does not reproduce the formatted wrapped output.
|
|
104
|
+
*/
|
|
105
|
+
export function unwrapFormattedJsh(formatted) {
|
|
106
|
+
const trimmed = formatted.endsWith('\n') ? formatted.slice(0, -1) : formatted;
|
|
107
|
+
const lines = trimmed.split('\n');
|
|
108
|
+
lines.shift();
|
|
109
|
+
lines.pop();
|
|
110
|
+
const body = lines.map((line) => (line.startsWith('\t') ? line.slice(1) : line));
|
|
111
|
+
return body.join('\n') + '\n';
|
|
112
|
+
}
|
package/lib.mjs
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* biome-jsh — pure orchestration logic (no I/O, no process spawning).
|
|
3
|
+
*
|
|
4
|
+
* Parses Biome's `--reporter=github` annotation lines, shifts each diagnostic
|
|
5
|
+
* from the wrapped temp file back onto the real `.jsh`/`.bsh` file (line-space
|
|
6
|
+
* shift + path rewrite), and enumerates candidate `@biomejs/biome` binary
|
|
7
|
+
* locations. The I/O — file walking, temp files, spawning Biome, write-back —
|
|
8
|
+
* lives in `biome-jsh.mjs`. Keeping this module side-effect-free lets the
|
|
9
|
+
* span-shift + parsing logic be unit-tested without a Biome binary.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
// Matches a single GitHub Actions workflow-command annotation line, e.g.
|
|
15
|
+
// ::error title=lint/suspicious/noDoubleEquals,file=a.js,line=3,col=8::msg
|
|
16
|
+
// Group 1 is the level, group 2 the comma-separated `key=value` property
|
|
17
|
+
// block, group 3 the (possibly percent-encoded) message.
|
|
18
|
+
const ANNOTATION_RE = /^::(error|warning|notice)\s+(.*?)::([\s\S]*)$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse one `--reporter=github` line into `{ level, fields, message }`, or
|
|
22
|
+
* `null` when the line is not an annotation (Biome also prints a human summary
|
|
23
|
+
* to stdout/stderr that we ignore). `fields` preserves Biome's key order.
|
|
24
|
+
*/
|
|
25
|
+
export function parseGithubAnnotation(line) {
|
|
26
|
+
const match = ANNOTATION_RE.exec(line);
|
|
27
|
+
if (!match) return null;
|
|
28
|
+
const [, level, propsStr, message] = match;
|
|
29
|
+
const fields = {};
|
|
30
|
+
for (const pair of propsStr.split(',')) {
|
|
31
|
+
const eq = pair.indexOf('=');
|
|
32
|
+
if (eq < 0) continue;
|
|
33
|
+
fields[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
34
|
+
}
|
|
35
|
+
return { level, fields, message };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Reconstruct a GitHub annotation line from a parsed `{ level, fields, message }`. */
|
|
39
|
+
export function formatGithubAnnotation(annotation) {
|
|
40
|
+
const props = Object.entries(annotation.fields)
|
|
41
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
42
|
+
.join(',');
|
|
43
|
+
return `::${annotation.level} ${props}::${annotation.message}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Map a diagnostic computed on the wrapped temp file back onto the real file:
|
|
48
|
+
* rewrite `file=` to `realPath` and subtract `lineDelta` from `line`/`endLine`
|
|
49
|
+
* (clamped ≥ 1). Because the wrapper prefix is one newline-terminated line at
|
|
50
|
+
* column 0, columns are already correct and only the line moves.
|
|
51
|
+
*/
|
|
52
|
+
export function shiftAnnotationToRealFile(annotation, realPath, lineDelta) {
|
|
53
|
+
const fields = { ...annotation.fields };
|
|
54
|
+
if (fields.file !== undefined) fields.file = realPath;
|
|
55
|
+
for (const key of ['line', 'endLine']) {
|
|
56
|
+
if (fields[key] === undefined) continue;
|
|
57
|
+
const parsed = Number.parseInt(fields[key], 10);
|
|
58
|
+
if (Number.isFinite(parsed)) fields[key] = String(Math.max(1, parsed - lineDelta));
|
|
59
|
+
}
|
|
60
|
+
return { level: annotation.level, fields, message: annotation.message };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Transform every annotation line of Biome's github-reporter `stdout`: keep
|
|
65
|
+
* non-annotation lines untouched, and rewrite each annotation's `file=` to
|
|
66
|
+
* `realPath` while shifting its `line`/`endLine` back by `lineDelta` (0 for
|
|
67
|
+
* pass-through, non-wrapped files; the prefix line count for wrapped ones).
|
|
68
|
+
* Returns `{ lines, errorCount, warningCount }`.
|
|
69
|
+
*/
|
|
70
|
+
export function remapGithubOutput(stdout, realPath, lineDelta) {
|
|
71
|
+
const lines = [];
|
|
72
|
+
let errorCount = 0;
|
|
73
|
+
let warningCount = 0;
|
|
74
|
+
for (const raw of splitLines(stdout)) {
|
|
75
|
+
const annotation = parseGithubAnnotation(raw);
|
|
76
|
+
if (!annotation) {
|
|
77
|
+
lines.push(raw);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (annotation.level === 'error') errorCount++;
|
|
81
|
+
else if (annotation.level === 'warning') warningCount++;
|
|
82
|
+
lines.push(formatGithubAnnotation(shiftAnnotationToRealFile(annotation, realPath, lineDelta)));
|
|
83
|
+
}
|
|
84
|
+
return { lines, errorCount, warningCount };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function splitLines(text) {
|
|
88
|
+
if (text === '') return [];
|
|
89
|
+
const trimmed = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
90
|
+
return trimmed.split('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build a GitHub error annotation for a real file at `line` (used to surface
|
|
95
|
+
* "file is not formatted" without any wrapped-source coordinates).
|
|
96
|
+
*/
|
|
97
|
+
export function makeErrorAnnotation(file, message, line = 1) {
|
|
98
|
+
return {
|
|
99
|
+
level: 'error',
|
|
100
|
+
fields: {
|
|
101
|
+
title: 'format',
|
|
102
|
+
file,
|
|
103
|
+
line: String(line),
|
|
104
|
+
endLine: String(line),
|
|
105
|
+
col: '1',
|
|
106
|
+
endColumn: '1',
|
|
107
|
+
},
|
|
108
|
+
message,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Candidate `node_modules/.bin/biome` paths, walking up from each start
|
|
114
|
+
* directory to the filesystem root. Deduplicated, order-preserving. The CLI
|
|
115
|
+
* probes these with `existsSync` and uses the first hit.
|
|
116
|
+
*/
|
|
117
|
+
export function biomeBinCandidates(startDirs) {
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const candidates = [];
|
|
120
|
+
for (const start of startDirs) {
|
|
121
|
+
let dir = start;
|
|
122
|
+
for (;;) {
|
|
123
|
+
const candidate = join(dir, 'node_modules', '.bin', 'biome');
|
|
124
|
+
if (!seen.has(candidate)) {
|
|
125
|
+
seen.add(candidate);
|
|
126
|
+
candidates.push(candidate);
|
|
127
|
+
}
|
|
128
|
+
const parent = dirname(dir);
|
|
129
|
+
if (parent === dir) break;
|
|
130
|
+
dir = parent;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return candidates;
|
|
134
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ai-ecoverse/biome-jsh",
|
|
3
|
+
"version": "0.0.0-development",
|
|
4
|
+
"description": "A jsh-aware Biome runner: lint/format .jsh/.bsh shell scripts (AsyncFunction bodies) without false return/await parse errors.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"biome-jsh": "./biome-jsh.mjs"
|
|
9
|
+
},
|
|
10
|
+
"main": "./lib.mjs",
|
|
11
|
+
"files": [
|
|
12
|
+
"biome-jsh.mjs",
|
|
13
|
+
"lib.mjs",
|
|
14
|
+
"jsh-biome-source.mjs",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@biomejs/biome": "^2.5.4"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/ai-ecoverse/slicc",
|
|
26
|
+
"directory": "packages/dev-tools/biome-jsh"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
}
|
|
31
|
+
}
|