@gjsify/oxlint-plugin-gjsify 0.53.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/README.md +122 -0
- package/lib/deferred-process-exit.d.ts +2 -0
- package/lib/deferred-process-exit.js +150 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +36 -0
- package/lib/no-css-side-effect-import.d.ts +2 -0
- package/lib/no-css-side-effect-import.js +191 -0
- package/lib/no-literal-widget-label.d.ts +2 -0
- package/lib/no-literal-widget-label.js +190 -0
- package/lib/prefer-blueprint-template.d.ts +2 -0
- package/lib/prefer-blueprint-template.js +380 -0
- package/lib/register-class-order.d.ts +2 -0
- package/lib/register-class-order.js +140 -0
- package/lib/spawn-node-binary.d.ts +2 -0
- package/lib/spawn-node-binary.js +113 -0
- package/lib/todo-needs-anchor.d.ts +2 -0
- package/lib/todo-needs-anchor.js +131 -0
- package/lib/types.d.ts +78 -0
- package/lib/types.js +10 -0
- package/lib/walk.d.ts +26 -0
- package/lib/walk.js +73 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# @gjsify/oxlint-plugin-gjsify
|
|
2
|
+
|
|
3
|
+
An [oxlint](https://oxc.rs/docs/guide/usage/linter/linter.html) JS plugin carrying the lint
|
|
4
|
+
rules gjsify needs for GJS and GTK code — and that oxlint's own Rust rule set cannot have,
|
|
5
|
+
because each one is a claim about a HOST (GJS's exit semantics, `xgettext`'s view of a
|
|
6
|
+
Blueprint file) rather than about JavaScript.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install --save-dev @gjsify/oxlint-plugin-gjsify oxlint
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`oxlint` is a peer dependency: the plugin runs inside oxlint's JavaScript plugin host, so the
|
|
15
|
+
two versions have to be the ones you actually run.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Add the package to `jsPlugins` in `.oxlintrc.json` and switch on the rules you want. The
|
|
20
|
+
rules are namespaced `gjsify/<rule>`, from the plugin's `meta.name`:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"jsPlugins": ["@gjsify/oxlint-plugin-gjsify"],
|
|
25
|
+
"rules": {
|
|
26
|
+
"gjsify/prefer-blueprint-template": "error",
|
|
27
|
+
"gjsify/no-literal-widget-label": "error"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then run `npx oxlint .`, or `gjsify lint` if your project uses the gjsify CLI (which spawns
|
|
33
|
+
the same oxlint). `gjsify fix` applies the autofixes below.
|
|
34
|
+
|
|
35
|
+
> **A missing plugin is a hard failure, not a silent skip.** oxlint reports
|
|
36
|
+
> `Failed to load JS plugin` and exits non-zero rather than linting without the rules, so a
|
|
37
|
+
> scaffolded project that names the plugin but has not installed it fails loudly at the
|
|
38
|
+
> config step. If you see that message, run the install above.
|
|
39
|
+
|
|
40
|
+
### Which rules to enable
|
|
41
|
+
|
|
42
|
+
Not all seven suit every project. Two are about GTK application interface and are the reason
|
|
43
|
+
this package exists for most gjsify apps; the rest are narrower.
|
|
44
|
+
|
|
45
|
+
| Rule | Reports | Autofix |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `prefer-blueprint-template` | a widget subclass that assembles its interface in TypeScript instead of declaring it in a Blueprint `.blp` | no — writing the `.blp` is the work |
|
|
48
|
+
| `no-literal-widget-label` | user-visible text hard-coded into a widget, where no catalogue can ever hold it | no — the repair is context-dependent |
|
|
49
|
+
| `register-class-order` | static GObject metadata declared below the `GObject.registerClass` static block | **yes** — hoists the fields |
|
|
50
|
+
| `deferred-process-exit` | a bare `process.exit()` with statements after it, which does not halt under GJS | no — usually `return process.exit(...)` |
|
|
51
|
+
| `todo-needs-anchor` | a `TODO`/`FIXME`/`HACK`/`XXX` that names nothing to track it | no |
|
|
52
|
+
| `no-css-side-effect-import` | a bare `import '<something that is CSS>'`, which tree-shakes away to nothing under a gjsify build | no |
|
|
53
|
+
| `spawn-node-binary` | `spawn(process.execPath, …)` under the GJS bundle, where that path is `gjs-console` | no |
|
|
54
|
+
|
|
55
|
+
Two of them are aimed at this repository rather than at consumers, and are listed here only
|
|
56
|
+
because they ship in the same plugin: `spawn-node-binary` fires on `spawn(process.execPath, …)`,
|
|
57
|
+
which this repo scopes to its own CLI sources in `.oxlintrc.json` and a consumer has to scope
|
|
58
|
+
itself (or leave off), and `no-css-side-effect-import` depends on how YOUR build treats CSS —
|
|
59
|
+
under a real CSS pipeline the side-effect form is correct. Enable what you have measured.
|
|
60
|
+
|
|
61
|
+
### Scope-outs
|
|
62
|
+
|
|
63
|
+
Library code that implements widgets FOR others — renderers, storybook fixtures, component
|
|
64
|
+
catalogues — legitimately builds widgets at runtime, and `prefer-blueprint-template` reports
|
|
65
|
+
every one. Scope those directories off in `.oxlintrc.json` rather than disabling line by line
|
|
66
|
+
(the paths below are examples, not paths of yours):
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"overrides": [
|
|
71
|
+
{
|
|
72
|
+
"files": ["packages/web/adwaita-app/**", "**/*.story.ts"],
|
|
73
|
+
"rules": { "gjsify/prefer-blueprint-template": "off" }
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
With `reportUnusedDisableDirectives` on — which `gjsify format --init` writes — a directive that
|
|
80
|
+
suppresses nothing is itself an error, so a scope-out that stops applying retires itself.
|
|
81
|
+
|
|
82
|
+
## Rules
|
|
83
|
+
|
|
84
|
+
Each rule's own trade-offs — what it deliberately does NOT report, and why — are documented
|
|
85
|
+
with its implementation:
|
|
86
|
+
|
|
87
|
+
- `gjsify/register-class-order` — enforces that static GObject metadata fields (`GTypeName`, `Properties`, `Signals`, etc.) are declared above `GObject.registerClass` static blocks, with autofix to reorder them automatically.
|
|
88
|
+
- `gjsify/deferred-process-exit` — a bare `process.exit()` does not halt under GJS (no atexit; the GLib main loop may still be armed): the call returns, the statements after it still run, and the requested exit code can be lost. The rule flags a statement containing a bare `process.exit()` when another statement follows it in the same statement list; tail-position exits are deliberately not flagged, and there is no autofix (the right repair — usually `return process.exit(...)` — is context-dependent, and a wrong one is a syntax error).
|
|
89
|
+
- `gjsify/todo-needs-anchor` — a deferral marker (`TODO`, `FIXME`, `HACK`, `XXX`) that OPENS a comment line must name where it is tracked: `#123`, a forge issue URL, `open-todos` for the `status/` ledger, or the `fixed upstream in …` shim note. A bare marker has no owner and no retirement path — nothing fails when the work lands and nothing fails when it is dropped. Markers read from the comment stream rather than the source text, so one inside a string is not a finding; mid-sentence occurrences are prose about markers, not markers. No autofix (only a human knows whether the repair is fixing it, filing an issue, or adding a ledger entry).
|
|
90
|
+
|
|
91
|
+
- `gjsify/no-css-side-effect-import` — a bare `import '<something that is CSS>';` registers nothing under a gjsify build. `cssAsStringPlugin` emits `export default "<css>"`, a module with no side effect, so the import is tree-shaken and the build exits 0. Measured on 0.41.0: a probe entry whose only statement was `import '@gjsify/adwaita-fonts';` — the line `@gjsify/adwaita-web` carried for its whole life while declaring `font-family: 'Adwaita Sans'` — built to a **0-byte bundle with zero `@font-face`**. The rule resolves the target package's `exports` off disk, so the extensionless shape (`@gjsify/adwaita-fonts` → `index.css`) is caught too; a VALUE import and a JS side-effect import stay silent. No autofix: deleting the line is right where the stylesheet arrives some other way and wrong where the author meant the CSS to arrive, and only the author knows which. Under a real CSS pipeline the side-effect form IS correct — say so with a per-line `// oxlint-disable-next-line gjsify/no-css-side-effect-import -- <reason>`, which `reportUnusedDisableDirectives` retires the day the import goes; this repo has exactly one such site, an Astro component.
|
|
92
|
+
- `gjsify/prefer-blueprint-template` — a `Gtk`/`Adw` widget subclass that ASSEMBLES its interface in TypeScript with no Blueprint `Template`. Measured across this workspace 2026-08: Learn6502 carries a whole application in 24 `.blp` files with 8 programmatic constructions and reports zero; two apps that grew the other way report 31 template-free widget classes between them. The cost is not style — a caption assigned from TypeScript carries no `translatable` attribute, so `xgettext` never sees it and no catalogue can hold it: the interface is untranslatABLE while looking merely untranslated, which is why nobody files it. Requires BOTH signals before reporting (constructs a `Gtk`/`Adw` type AND calls a parenting method like `append`/`set_child`/`add_row`), because either alone is ordinary code — a class that only builds a `Gtk.Adjustment` is silent, and so is one that reparents an existing widget. Neither signal can type-check its operand: the rule sees `new Gtk.X` and a method NAME, never a GType. Where a non-widget's own API collides with a parenting name — `Gtk.StringList` filled by `.append()` being the case that occurs — a deny-list of known non-widget constructions keeps it quiet. `add_action` is deliberately NOT a parenting method: it is `Gio.ActionMap.add_action`, and treating it as one reported every widget that registers an action. A template that fills DATA-DRIVEN children at runtime is the intended pattern and is silent too (it has a `Template`). `Gtk.Application`/`Adw.Application` are excluded: an application object is not a widget and legitimately builds its own `Gtk.CssProvider` and `Adw.AboutDialog`. No autofix — writing the `.blp` is the work. Library code that implements widgets FOR others, plus fixtures and demos, are scoped off in `.oxlintrc.json` rather than disabled line by line.
|
|
93
|
+
- `gjsify/no-literal-widget-label` — user-visible text hard-coded into a widget from TypeScript: a bare string literal in a prose position (`label`, `title`, `subtitle`, `description`, `heading`, `body`, `tooltip-text`, `placeholder-text`, `secondary-text`) of a `new Gtk.X`/`new Adw.X` constructor object or the matching `set_*` call. This is the half of the problem that survives even after a class HAS a template. Extraction sees `translatable="yes"` from Blueprint and `_("…")` from TypeScript; a bare literal is neither, so the string reaches no catalogue. Two repairs and the rule accepts both: move it into the co-located `.blp` as `title: _("…")`, or wrap it in place as `_("…")` when a runtime value picks it — wrapping is why there is no autofix. Also covers the captions that arrive as a LATER argument — `Adw.AlertDialog`'s `add_response(id, label)` and `set_response_label(id, label)`, which are the only way that dialog gets its buttons: without them a dialog whose `heading` the rule reports would still ship English-only buttons, in the same function (measured: 24 live call sites across two consumer apps). Only PROSE positions are checked: `icon-name`, `css-classes`, `action-name`, a stack page's `name` and an `Adw.EntryRow`'s `text` (user data, not a caption) are deliberately absent, and a literal with no letters (`"—"`, `"%"`, `"3"`) is not reported, because a rule that flags punctuation loses its audience. A default behind a caller override (`options.label ?? 'OK'`) is not a literal in this position and stays silent — that IS the escape hatch for library code with no text domain of its own. The setter half matches on method NAME alone, so a non-GTK object with a `set_title` method is reported too; the message names the Blueprint PROPERTY (`title`), never the setter.
|
|
94
|
+
- `gjsify/spawn-node-binary` — `spawn(process.execPath, …)` means "start the current runtime again", which is right in a Node program and wrong in a DUAL-HOST one: under `dist/cli.gjs.mjs` that path is `gjs-console`, so the spawn hands a Node script to GJS, which runs it and dies inside the payload. Use `nodeBinary()` from `utils/run-node.ts`. Scoped to `packages/infra/cli/src/**`.
|
|
95
|
+
|
|
96
|
+
Part of the [gjsify](https://github.com/gjsify/gjsify) project — Node.js and Web APIs for GJS (GNOME JavaScript).
|
|
97
|
+
|
|
98
|
+
### How gjsify itself wires it
|
|
99
|
+
|
|
100
|
+
The gjsify repository runs all seven rules. Its root `.oxlintrc.json` names the SOURCE path
|
|
101
|
+
(`./packages/infra/oxlint-plugin-gjsify/src/index.ts`) rather than the package name, because
|
|
102
|
+
oxlint `import()`s a `.ts` file directly through Node's type-stripping and `gjsify lint` must
|
|
103
|
+
work on an unbuilt checkout — the same rules, loaded without a build step:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"jsPlugins": ["./packages/infra/oxlint-plugin-gjsify/src/index.ts"],
|
|
108
|
+
"rules": {
|
|
109
|
+
"gjsify/register-class-order": "error",
|
|
110
|
+
"gjsify/deferred-process-exit": "error",
|
|
111
|
+
"gjsify/todo-needs-anchor": "error",
|
|
112
|
+
"gjsify/spawn-node-binary": "error",
|
|
113
|
+
"gjsify/no-css-side-effect-import": "error",
|
|
114
|
+
"gjsify/prefer-blueprint-template": "error",
|
|
115
|
+
"gjsify/no-literal-widget-label": "error"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// `gjsify/deferred-process-exit` — a bare `process.exit()` does not halt under
|
|
2
|
+
// GJS, so code after it still runs.
|
|
3
|
+
//
|
|
4
|
+
// GJS has no atexit hook and the GLib main loop may still be armed, so
|
|
5
|
+
// `process.exit()` is DEFERRED: the call returns, the surrounding function
|
|
6
|
+
// keeps executing, and the exit code the caller asked for can be lost entirely.
|
|
7
|
+
// On Node/bun/deno the same call halts immediately, which is what makes this so
|
|
8
|
+
// easy to ship — the bug is invisible on the runtime most people test on.
|
|
9
|
+
//
|
|
10
|
+
// Measured on `gjsify storybook` in a directory with no stories:
|
|
11
|
+
//
|
|
12
|
+
// if (initialCount === 0) {
|
|
13
|
+
// console.error('No *.story.ts files found under src');
|
|
14
|
+
// process.exit(1); // ← last statement of ITS block …
|
|
15
|
+
// }
|
|
16
|
+
// console.log(`Found ${initialCount} story file(s) …`); // … but this ran
|
|
17
|
+
//
|
|
18
|
+
// $ bunx @gjsify/cli storybook → one message, exit 1
|
|
19
|
+
// $ gjsify storybook → BOTH messages, exit 0
|
|
20
|
+
//
|
|
21
|
+
// A command that reports a failure and exits 0 is worse than a crash: a script
|
|
22
|
+
// or CI step wrapping it reads success.
|
|
23
|
+
//
|
|
24
|
+
// The fix is `return process.exit(…)` — the `return` is what actually halts the
|
|
25
|
+
// handler; the deferred exit then carries its code on the way out.
|
|
26
|
+
//
|
|
27
|
+
// SCOPE — this rule flags the DANGEROUS shape, not every bare call. An exit in
|
|
28
|
+
// tail position (nothing follows it, anywhere up the statement list) is fine,
|
|
29
|
+
// and demanding `return` there would be style, not correctness. What it looks
|
|
30
|
+
// for is: a statement that CONTAINS a bare `process.exit()` while another
|
|
31
|
+
// statement follows it in the same block. That is precisely the case where the
|
|
32
|
+
// deferral changes behaviour.
|
|
33
|
+
//
|
|
34
|
+
// Deliberately NOT autofixable: the correct repair depends on context (`return
|
|
35
|
+
// process.exit()` inside a function, restructuring at module top level where
|
|
36
|
+
// `return` is a syntax error), and a wrong autofix here produces unparseable
|
|
37
|
+
// source. Reference: AGENTS.md § Lint & format, and `showcase.ts`, which
|
|
38
|
+
// carries the same reasoning inline at each of its call sites.
|
|
39
|
+
/** Node types that open a new function scope — an exit inside one is not ours. */
|
|
40
|
+
const FUNCTION_TYPES = new Set([
|
|
41
|
+
'FunctionDeclaration',
|
|
42
|
+
'FunctionExpression',
|
|
43
|
+
'ArrowFunctionExpression',
|
|
44
|
+
'TSDeclareFunction',
|
|
45
|
+
'TSEmptyBodyFunctionExpression',
|
|
46
|
+
]);
|
|
47
|
+
/** Statement-list containers whose `body` is an ordered list of statements. */
|
|
48
|
+
const STATEMENT_LIST_KEYS = {
|
|
49
|
+
BlockStatement: 'body',
|
|
50
|
+
StaticBlock: 'body',
|
|
51
|
+
SwitchCase: 'consequent',
|
|
52
|
+
Program: 'body',
|
|
53
|
+
};
|
|
54
|
+
/** Is this node the expression statement `process.exit(...)`? */
|
|
55
|
+
function isBareProcessExitStatement(node) {
|
|
56
|
+
if (node.type !== 'ExpressionStatement')
|
|
57
|
+
return false;
|
|
58
|
+
const expr = node.expression;
|
|
59
|
+
if (!expr || expr.type !== 'CallExpression')
|
|
60
|
+
return false;
|
|
61
|
+
const callee = expr.callee;
|
|
62
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed === true)
|
|
63
|
+
return false;
|
|
64
|
+
const object = callee.object;
|
|
65
|
+
const property = callee.property;
|
|
66
|
+
return (object?.type === 'Identifier' &&
|
|
67
|
+
object.name === 'process' &&
|
|
68
|
+
property?.type === 'Identifier' &&
|
|
69
|
+
property.name === 'exit');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Find a bare `process.exit()` statement inside `node`'s subtree, without
|
|
73
|
+
* descending into nested functions (a callback's exit belongs to the callback,
|
|
74
|
+
* not to the statement list being examined).
|
|
75
|
+
*
|
|
76
|
+
* Returns the offending node, or `null`.
|
|
77
|
+
*/
|
|
78
|
+
function findBareExitInSubtree(node) {
|
|
79
|
+
// A function body runs later (or never) — its exits are evaluated against
|
|
80
|
+
// ITS own statement lists, when the visitor reaches them. Guarded at ENTRY
|
|
81
|
+
// (not only when descending into children) because the statement handed in
|
|
82
|
+
// by `checkStatementList` can itself be a hoisted FunctionDeclaration: a
|
|
83
|
+
// tail-position `process.exit()` inside `function forceExit() { … }` must
|
|
84
|
+
// not be flagged just because another top-level declaration follows the
|
|
85
|
+
// function in the module body.
|
|
86
|
+
if (FUNCTION_TYPES.has(node.type))
|
|
87
|
+
return null;
|
|
88
|
+
if (isBareProcessExitStatement(node))
|
|
89
|
+
return node;
|
|
90
|
+
for (const key of Object.keys(node)) {
|
|
91
|
+
// `parent` would walk back up and recurse forever; `range` is data.
|
|
92
|
+
if (key === 'parent' || key === 'range')
|
|
93
|
+
continue;
|
|
94
|
+
const value = node[key];
|
|
95
|
+
const children = Array.isArray(value) ? value : [value];
|
|
96
|
+
for (const child of children) {
|
|
97
|
+
if (!child || typeof child !== 'object')
|
|
98
|
+
continue;
|
|
99
|
+
const childNode = child;
|
|
100
|
+
if (typeof childNode.type !== 'string')
|
|
101
|
+
continue;
|
|
102
|
+
const found = findBareExitInSubtree(childNode);
|
|
103
|
+
if (found)
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
export const deferredProcessExitRule = {
|
|
110
|
+
meta: {
|
|
111
|
+
// No autofix — see the SCOPE note in the file header.
|
|
112
|
+
fixable: false,
|
|
113
|
+
},
|
|
114
|
+
create(context) {
|
|
115
|
+
const reported = new Set();
|
|
116
|
+
const checkStatementList = (node) => {
|
|
117
|
+
const key = STATEMENT_LIST_KEYS[node.type];
|
|
118
|
+
if (!key)
|
|
119
|
+
return;
|
|
120
|
+
const body = node[key];
|
|
121
|
+
if (!Array.isArray(body))
|
|
122
|
+
return;
|
|
123
|
+
// Only statements with something AFTER them can fall through.
|
|
124
|
+
for (let i = 0; i < body.length - 1; i++) {
|
|
125
|
+
const stmt = body[i];
|
|
126
|
+
if (!stmt || typeof stmt.type !== 'string')
|
|
127
|
+
continue;
|
|
128
|
+
const offender = findBareExitInSubtree(stmt);
|
|
129
|
+
if (!offender || reported.has(offender.start))
|
|
130
|
+
continue;
|
|
131
|
+
reported.add(offender.start);
|
|
132
|
+
context.report({
|
|
133
|
+
message: 'Bare `process.exit()` does not halt under GJS (no atexit, the GLib loop may still ' +
|
|
134
|
+
'be armed) — the statements after this one still run, and the exit code can be lost. ' +
|
|
135
|
+
'In an async or void-returning function write `return process.exit(...)`. In a ' +
|
|
136
|
+
'SYNCHRONOUS function that returns a value, `return` is the WRONG repair: under GJS ' +
|
|
137
|
+
'the exit yields a forever-pending Promise, so the caller receives that Promise as ' +
|
|
138
|
+
'the return value and keeps going — throw after the exit instead, and say so in a ' +
|
|
139
|
+
'reasoned oxlint-disable comment.',
|
|
140
|
+
node: offender,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const visitor = {};
|
|
145
|
+
for (const type of Object.keys(STATEMENT_LIST_KEYS)) {
|
|
146
|
+
visitor[type] = checkStatementList;
|
|
147
|
+
}
|
|
148
|
+
return visitor;
|
|
149
|
+
},
|
|
150
|
+
};
|
package/lib/index.d.ts
ADDED
package/lib/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// oxlint JS plugin for gjsify — PUBLISHED as `@gjsify/oxlint-plugin-gjsify`.
|
|
2
|
+
//
|
|
3
|
+
// oxlint loads a plugin via `jsPlugins` in `.oxlintrc.json`, and its plugin host
|
|
4
|
+
// reads the module's DEFAULT export expecting a `{ meta: { name }, rules }` shape
|
|
5
|
+
// (see `refs/oxc/apps/oxlint/src-js/plugins/load.ts` → `registerPlugin`). The newer
|
|
6
|
+
// `definePlugin` helper is an identity function and is not exported by the
|
|
7
|
+
// published `oxlint@1.66/1.67`, so we export the plain object directly.
|
|
8
|
+
//
|
|
9
|
+
// TWO LOADING PATHS, one source. This repo's own `.oxlintrc.json` names the SOURCE
|
|
10
|
+
// path (`src/index.ts`) — oxlint `import()`s it directly and Node's type-stripping
|
|
11
|
+
// handles the types, so `gjsify lint` needs no build step — while a consumer names
|
|
12
|
+
// the PACKAGE and gets `lib/index.js` from `tsc` (see `tsconfig.build.json`).
|
|
13
|
+
// `rewriteRelativeImportExtensions` is what lets one set of `./x.ts` imports serve
|
|
14
|
+
// both, so neither path needs its own copy of the rules.
|
|
15
|
+
import { deferredProcessExitRule } from "./deferred-process-exit.js";
|
|
16
|
+
import { noCssSideEffectImportRule } from "./no-css-side-effect-import.js";
|
|
17
|
+
import { noLiteralWidgetLabelRule } from "./no-literal-widget-label.js";
|
|
18
|
+
import { preferBlueprintTemplateRule } from "./prefer-blueprint-template.js";
|
|
19
|
+
import { registerClassOrderRule } from "./register-class-order.js";
|
|
20
|
+
import { spawnNodeBinaryRule } from "./spawn-node-binary.js";
|
|
21
|
+
import { todoNeedsAnchorRule } from "./todo-needs-anchor.js";
|
|
22
|
+
const plugin = {
|
|
23
|
+
meta: {
|
|
24
|
+
name: 'gjsify',
|
|
25
|
+
},
|
|
26
|
+
rules: {
|
|
27
|
+
'deferred-process-exit': deferredProcessExitRule,
|
|
28
|
+
'no-css-side-effect-import': noCssSideEffectImportRule,
|
|
29
|
+
'no-literal-widget-label': noLiteralWidgetLabelRule,
|
|
30
|
+
'prefer-blueprint-template': preferBlueprintTemplateRule,
|
|
31
|
+
'register-class-order': registerClassOrderRule,
|
|
32
|
+
'spawn-node-binary': spawnNodeBinaryRule,
|
|
33
|
+
'todo-needs-anchor': todoNeedsAnchorRule,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
export default plugin;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// `gjsify/no-css-side-effect-import` — a bare `import '<something that is CSS>';`
|
|
2
|
+
// registers nothing in a gjsify build, and says so nowhere.
|
|
3
|
+
//
|
|
4
|
+
// THE MECHANISM. `cssAsStringPlugin` (`@gjsify/rolldown-plugin-gjsify`, composed by
|
|
5
|
+
// every `--app browser`/`--app gjs` build) hooks `load` for `.css`/`.scss`/`.sass`
|
|
6
|
+
// and returns `export default "<css>"` — a JS module, because Rolldown dropped CSS
|
|
7
|
+
// bundling and would otherwise error on the extension. A module whose whole body is
|
|
8
|
+
// a string literal has NO side effect, so a side-effect import of it is tree-shaken
|
|
9
|
+
// and the build exits 0.
|
|
10
|
+
//
|
|
11
|
+
// THE INCIDENT it was written for — a 0-byte bundle, exit 0, invisible on a GNOME
|
|
12
|
+
// host — is `docs/code-anti-patterns.md` § "a side-effect import that has no side
|
|
13
|
+
// effect". Repo-wide there were exactly TWO instances, and both had the convention
|
|
14
|
+
// already written down beside them (`@gjsify/adwaita-web`'s entry comment,
|
|
15
|
+
// `style.css.d.ts`): two files followed it and one did not, which is the
|
|
16
|
+
// measurement that turns a comment into a check.
|
|
17
|
+
//
|
|
18
|
+
// WHY A LINT RULE AND NOT THE PLUGIN, where the knowledge is: the hooks that
|
|
19
|
+
// could see a dropped module are argument-less no-ops on the
|
|
20
|
+
// `@gjsify/rolldown-native` bridge, so the diagnostic would exist under Node and
|
|
21
|
+
// not under GJS. Full reasoning, and the consumer-facing half that is still
|
|
22
|
+
// missing, in `status/open-todos/README.md`.
|
|
23
|
+
//
|
|
24
|
+
// WHAT IT FLAGS. An `ImportDeclaration` with NO specifiers whose source is CSS:
|
|
25
|
+
// either by extension (`./app.css`), or because the package it names exports CSS
|
|
26
|
+
// from that subpath (`@gjsify/adwaita-fonts` → `index.css`). The second half is
|
|
27
|
+
// the one that matters — a specifier with no extension is exactly the shape that
|
|
28
|
+
// reads like a JS side-effect module.
|
|
29
|
+
//
|
|
30
|
+
// WHAT IT DOES NOT FLAG. A VALUE import (`import css from './app.css'`) — that is
|
|
31
|
+
// the correct spelling, and what the app does with the string is its business. A
|
|
32
|
+
// bare import of anything that resolves to JS, which is the ordinary `/register`
|
|
33
|
+
// pattern and must stay silent.
|
|
34
|
+
//
|
|
35
|
+
// SCOPE. Repo-wide, no config override: the one legitimate site (an Astro component
|
|
36
|
+
// — Astro IS a CSS pipeline) carries a per-line reasoned disable, which
|
|
37
|
+
// `reportUnusedDisableDirectives` retires the day the import goes. The rule found
|
|
38
|
+
// that file on its first run; the author's own grep had missed it, not reading
|
|
39
|
+
// `.astro`.
|
|
40
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
41
|
+
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
42
|
+
/** Extensions `cssAsStringPlugin` claims in its `load` filter. */
|
|
43
|
+
const CSS_EXTENSIONS = /\.(css|s[ac]ss)(\?|#|$)/i;
|
|
44
|
+
/** Export conditions to follow, in the order a browser/ESM build resolves them. */
|
|
45
|
+
const CONDITIONS = ['browser', 'import', 'module', 'default', 'require'];
|
|
46
|
+
/** `@scope/pkg/sub/path` → `['@scope/pkg', './sub/path']`; `pkg` → `['pkg', '.']`. */
|
|
47
|
+
function splitSpecifier(specifier) {
|
|
48
|
+
const parts = specifier.split('/');
|
|
49
|
+
const name = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
50
|
+
const rest = specifier.slice(name.length);
|
|
51
|
+
return [name, rest.length === 0 ? '.' : `.${rest}`];
|
|
52
|
+
}
|
|
53
|
+
/** The first string an `exports` value yields under {@link CONDITIONS}, or `undefined`. */
|
|
54
|
+
function pickCondition(value) {
|
|
55
|
+
if (typeof value === 'string')
|
|
56
|
+
return value;
|
|
57
|
+
if (Array.isArray(value)) {
|
|
58
|
+
for (const entry of value) {
|
|
59
|
+
const picked = pickCondition(entry);
|
|
60
|
+
if (picked !== undefined)
|
|
61
|
+
return picked;
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
if (value === null || typeof value !== 'object')
|
|
66
|
+
return undefined;
|
|
67
|
+
const record = value;
|
|
68
|
+
for (const condition of CONDITIONS) {
|
|
69
|
+
if (condition in record) {
|
|
70
|
+
const picked = pickCondition(record[condition]);
|
|
71
|
+
if (picked !== undefined)
|
|
72
|
+
return picked;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The manifest of `name` as seen from `fromDir`, by walking `node_modules` upward.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately NOT `createRequire(...).resolve()`: that answers with a path only
|
|
81
|
+
* when the target FILE exists, and half the interesting targets are build outputs
|
|
82
|
+
* (`@gjsify/adwaita-web/style.css` → `dist/adwaita-web.css`) that a fresh checkout
|
|
83
|
+
* has not produced yet. A rule that goes quiet on an unbuilt tree is a rule that
|
|
84
|
+
* is quiet in CI's lint job, which is where it has to speak. `exports` is a
|
|
85
|
+
* DECLARATION and is present either way.
|
|
86
|
+
*/
|
|
87
|
+
function readPackageManifest(fromDir, name) {
|
|
88
|
+
let dir = fromDir;
|
|
89
|
+
for (;;) {
|
|
90
|
+
const manifestPath = join(dir, 'node_modules', ...name.split('/'), 'package.json');
|
|
91
|
+
if (existsSync(manifestPath)) {
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// A manifest we cannot parse is not evidence of CSS. Stay silent
|
|
97
|
+
// rather than accuse — this rule's whole value is that a hit means
|
|
98
|
+
// something.
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const parent = dirname(dir);
|
|
103
|
+
if (parent === dir)
|
|
104
|
+
return undefined;
|
|
105
|
+
dir = parent;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Where a bare specifier's declared entry points, per the target's `exports`/`main`. */
|
|
109
|
+
function declaredTarget(fromDir, specifier) {
|
|
110
|
+
const [name, subpath] = splitSpecifier(specifier);
|
|
111
|
+
const manifest = readPackageManifest(fromDir, name);
|
|
112
|
+
if (!manifest)
|
|
113
|
+
return undefined;
|
|
114
|
+
const exportsField = manifest.exports;
|
|
115
|
+
if (exportsField !== undefined && exportsField !== null) {
|
|
116
|
+
if (typeof exportsField === 'string' || Array.isArray(exportsField)) {
|
|
117
|
+
return subpath === '.' ? pickCondition(exportsField) : undefined;
|
|
118
|
+
}
|
|
119
|
+
const map = exportsField;
|
|
120
|
+
// A conditions-only map (no `.`-prefixed keys) IS the `.` entry.
|
|
121
|
+
const isSubpathMap = Object.keys(map).some((key) => key.startsWith('.'));
|
|
122
|
+
if (!isSubpathMap)
|
|
123
|
+
return subpath === '.' ? pickCondition(map) : undefined;
|
|
124
|
+
if (subpath in map)
|
|
125
|
+
return pickCondition(map[subpath]);
|
|
126
|
+
for (const [pattern, value] of Object.entries(map)) {
|
|
127
|
+
if (!pattern.includes('*'))
|
|
128
|
+
continue;
|
|
129
|
+
const [head, tail] = pattern.split('*');
|
|
130
|
+
if (!subpath.startsWith(head) || !subpath.endsWith(tail))
|
|
131
|
+
continue;
|
|
132
|
+
const star = subpath.slice(head.length, subpath.length - tail.length);
|
|
133
|
+
return pickCondition(value)?.replace('*', star);
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
return subpath === '.' ? manifest.main : subpath;
|
|
138
|
+
}
|
|
139
|
+
/** Does this import specifier name CSS, as written or as the target package declares? */
|
|
140
|
+
function resolvesToCss(specifier, fromDir) {
|
|
141
|
+
if (CSS_EXTENSIONS.test(specifier))
|
|
142
|
+
return true;
|
|
143
|
+
if (specifier.startsWith('.')) {
|
|
144
|
+
// A relative specifier with no CSS extension is JS. Extensionless relative
|
|
145
|
+
// imports do not resolve to `.css` under any loader this repo uses.
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
if (specifier.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(specifier))
|
|
149
|
+
return false;
|
|
150
|
+
const target = declaredTarget(fromDir, specifier);
|
|
151
|
+
return target !== undefined && CSS_EXTENSIONS.test(target);
|
|
152
|
+
}
|
|
153
|
+
export const noCssSideEffectImportRule = {
|
|
154
|
+
meta: {
|
|
155
|
+
// No autofix. Deleting the line is right where the stylesheet is applied
|
|
156
|
+
// some other way and WRONG where the author meant the CSS to arrive — the
|
|
157
|
+
// two repairs are opposite, and only the author knows which.
|
|
158
|
+
fixable: false,
|
|
159
|
+
},
|
|
160
|
+
create(context) {
|
|
161
|
+
const fromDir = dirname(resolvePath(context.filename));
|
|
162
|
+
return {
|
|
163
|
+
ImportDeclaration(node) {
|
|
164
|
+
const specifiers = node.specifiers;
|
|
165
|
+
if (Array.isArray(specifiers) && specifiers.length > 0)
|
|
166
|
+
return;
|
|
167
|
+
const source = node.source;
|
|
168
|
+
const value = source?.value;
|
|
169
|
+
if (typeof value !== 'string')
|
|
170
|
+
return;
|
|
171
|
+
if (!resolvesToCss(value, fromDir))
|
|
172
|
+
return;
|
|
173
|
+
context.report({
|
|
174
|
+
message: `\`import '${value}';\` imports CSS for a SIDE EFFECT it does not have. Every gjsify ` +
|
|
175
|
+
'`--app browser`/`--app gjs` build loads CSS through `cssAsStringPlugin`, which emits ' +
|
|
176
|
+
'`export default "<css>"` — a module with no side effect, so this import is tree-shaken ' +
|
|
177
|
+
'and the build exits 0 with the stylesheet nowhere in it (measured: a probe entry whose ' +
|
|
178
|
+
'only statement was this import produced a 0-byte bundle). Import the VALUE and apply it ' +
|
|
179
|
+
`(\`import css from '${value}'\`, then a <style> whose textContent is that string), or ` +
|
|
180
|
+
'drop the line if the stylesheet is already applied elsewhere. For a FONT that is still ' +
|
|
181
|
+
'only half of it: a relative `url()` inside the recovered string resolves against the ' +
|
|
182
|
+
'document, and an `--app browser` build emits one file and no assets, so the rule parses ' +
|
|
183
|
+
'and the face 404s — it needs a `data:` URI or an asset-emitting pipeline. Under a real ' +
|
|
184
|
+
'CSS pipeline (Vite/webpack) the side-effect form IS correct — say so in a reasoned ' +
|
|
185
|
+
'oxlint-disable comment.',
|
|
186
|
+
node,
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
};
|