@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 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,2 @@
1
+ import type { Rule } from './types.ts';
2
+ export declare const deferredProcessExitRule: Rule;
@@ -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
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from './types.ts';
2
+ declare const plugin: Plugin;
3
+ export default plugin;
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,2 @@
1
+ import type { Rule } from './types.ts';
2
+ export declare const noCssSideEffectImportRule: Rule;
@@ -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
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from './types.ts';
2
+ export declare const noLiteralWidgetLabelRule: Rule;