@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.
@@ -0,0 +1,140 @@
1
+ // `gjsify/register-class-order` — keep GObject metadata fields above the
2
+ // `GObject.registerClass` static block.
3
+ //
4
+ // GJS classes register their GObject type from a `static { … }` block:
5
+ //
6
+ // class MyWidget extends Gtk.Widget {
7
+ // static {
8
+ // GObject.registerClass({ GTypeName: 'MyWidget', … }, this);
9
+ // }
10
+ // static Properties = { … }; // ← read by registerClass, declared AFTER it
11
+ // }
12
+ //
13
+ // The alternate GJS idiom puts the GObject metadata in `static` class fields
14
+ // (`static GTypeName`, `static Properties`, `static InternalChildren`, …) that
15
+ // `GObject.registerClass(this)` reads off the class. When such a field is
16
+ // declared AFTER the `static {}` block that calls `registerClass`, the
17
+ // registration runs before the field is assigned — the metadata is silently
18
+ // ignored. This rule flags those fields and hoists them above the block.
19
+ //
20
+ // Refs: GNOME/gjs#704, gjsify/ts-for-gir#410.
21
+ /**
22
+ * GObject metadata keys that `GObject.registerClass` reads off a class (whether
23
+ * passed inline as the first argument or declared as `static` class fields).
24
+ * A `static` field whose key is one of these and that sits AFTER the
25
+ * registerClass static block is the bug this rule targets.
26
+ */
27
+ const GOBJECT_METADATA_KEYS = new Set([
28
+ 'GTypeName',
29
+ 'GTypeFlags',
30
+ 'Properties',
31
+ 'Signals',
32
+ 'InternalChildren',
33
+ 'Children',
34
+ 'Template',
35
+ 'CssName',
36
+ 'CssTemplate',
37
+ 'Implements',
38
+ 'Requires',
39
+ ]);
40
+ function isStaticBlockCallingRegisterClass(el) {
41
+ if (el.type !== 'StaticBlock')
42
+ return false;
43
+ const body = el.body;
44
+ for (const stmt of body) {
45
+ if (stmt.type !== 'ExpressionStatement')
46
+ continue;
47
+ const expr = stmt.expression;
48
+ if (!expr || expr.type !== 'CallExpression')
49
+ continue;
50
+ const callee = expr.callee;
51
+ // Match `GObject.registerClass(...)` (and any `<X>.registerClass(...)`).
52
+ if (callee &&
53
+ callee.type === 'MemberExpression' &&
54
+ !callee.computed &&
55
+ callee.property?.type === 'Identifier' &&
56
+ callee.property.name === 'registerClass') {
57
+ return true;
58
+ }
59
+ }
60
+ return false;
61
+ }
62
+ /** Read the static-property key name when it is a plain (non-computed) identifier. */
63
+ function staticMetadataKeyName(el) {
64
+ if (el.type !== 'PropertyDefinition' && el.type !== 'TSAbstractPropertyDefinition')
65
+ return null;
66
+ const field = el;
67
+ if (!field.static || field.computed)
68
+ return null;
69
+ const key = field.key;
70
+ if (key.type === 'Identifier' && typeof key.name === 'string')
71
+ return key.name;
72
+ return null;
73
+ }
74
+ export const registerClassOrderRule = {
75
+ meta: {
76
+ fixable: 'code',
77
+ },
78
+ create(context) {
79
+ return {
80
+ ClassBody(node) {
81
+ const elements = node.body;
82
+ // Find the FIRST static block that calls registerClass.
83
+ let blockIndex = -1;
84
+ let block = null;
85
+ for (let i = 0; i < elements.length; i++) {
86
+ if (isStaticBlockCallingRegisterClass(elements[i])) {
87
+ blockIndex = i;
88
+ block = elements[i];
89
+ break;
90
+ }
91
+ }
92
+ if (blockIndex === -1 || block === null)
93
+ return;
94
+ // Collect static GObject-metadata fields that appear AFTER it.
95
+ const offending = [];
96
+ for (let i = blockIndex + 1; i < elements.length; i++) {
97
+ const key = staticMetadataKeyName(elements[i]);
98
+ if (key !== null && GOBJECT_METADATA_KEYS.has(key)) {
99
+ offending.push(elements[i]);
100
+ }
101
+ }
102
+ if (offending.length === 0)
103
+ return;
104
+ const blockNode = block;
105
+ const sourceText = context.sourceCode.text;
106
+ // One report per offending field for precise diagnostics, but a
107
+ // SINGLE combined autofix attached to the first report only.
108
+ // Reason: every field's hoist inserts at the same anchor (before
109
+ // the static block), so per-field fixes would conflict and oxlint
110
+ // applies only one per pass — `oxlint --fix` would need multiple
111
+ // runs to converge. A single combined fix hoists all fields in one
112
+ // pass and removes them (plus trailing whitespace, so no blank gap
113
+ // is left behind).
114
+ const combinedFix = (fixer) => {
115
+ const hoisted = offending.map((f) => context.sourceCode.getText(f)).join('\n\n ');
116
+ const fixes = [fixer.insertTextBefore(blockNode, `${hoisted}\n\n `)];
117
+ for (const f of offending) {
118
+ let removeEnd = f.end;
119
+ while (removeEnd < sourceText.length && /[ \t\r\n]/.test(sourceText[removeEnd])) {
120
+ removeEnd++;
121
+ }
122
+ fixes.push(fixer.removeRange([f.start, removeEnd]));
123
+ }
124
+ return fixes;
125
+ };
126
+ offending.forEach((field, idx) => {
127
+ const keyName = staticMetadataKeyName(field) ?? 'field';
128
+ context.report({
129
+ message: `GObject metadata field \`static ${keyName}\` is declared after the \`GObject.registerClass\` ` +
130
+ `static block — registerClass runs before it is assigned, so the metadata is ignored. ` +
131
+ `Move it above the static block.`,
132
+ node: field,
133
+ // Attach the combined fix to the first report only.
134
+ ...(idx === 0 ? { fix: combinedFix } : {}),
135
+ });
136
+ });
137
+ },
138
+ };
139
+ },
140
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from './types.ts';
2
+ export declare const spawnNodeBinaryRule: Rule;
@@ -0,0 +1,113 @@
1
+ // `gjsify/spawn-node-binary` — spawning `process.execPath` assumes the CLI runs
2
+ // under Node. Under the GJS bundle it does not, and the spawn starts the WRONG
3
+ // interpreter.
4
+ //
5
+ // `spawn(process.execPath, […])` is the documented portable way to start a
6
+ // second copy of the current runtime, and that is exactly what makes it wrong
7
+ // here: this CLI is a DUAL-HOST program. Run from `lib/index.js` the current
8
+ // runtime is Node; run from `dist/cli.gjs.mjs` it is GJS, where
9
+ // `@gjsify/process` resolves `execPath` honestly to the interpreter binary
10
+ // (`/proc/self/exe` → `/usr/bin/gjs-console` on Linux; `gjs` found on PATH on
11
+ // macOS, which has no procfs — `@gjsify/utils`' `hostExecPath()`). Handing a Node script to that binary does not fail
12
+ // with a missing-interpreter error — it RUNS, under the wrong engine, and dies
13
+ // somewhere inside the payload.
14
+ //
15
+ // Measured on `gjsify workspace @gjsify/create-app build` with a GJS-hosted CLI
16
+ // and `@gjsify/tsc`'s bundle not yet built (the normal state of a cold tree —
17
+ // `build:infra` builds `@gjsify/create-app` fifth and `@gjsify/tsc` far later):
18
+ //
19
+ // JS ERROR: ReferenceError: module is not defined
20
+ // @/…/node_modules/typescript/lib/tsc.js:8:1
21
+ // script "build" exited with code 1
22
+ //
23
+ // …because `commands/tsc.ts` spawned `process.execPath` for its upstream-`tsc`
24
+ // fallback. On a host where `execPath` resolves to a gjsify launcher instead of
25
+ // the raw interpreter, the same line re-executes the CLI and yargs reports the
26
+ // tsc entry as `Unknown argument: …/typescript/lib/tsc.js`. One defect, two
27
+ // faces, both invisible on Node.
28
+ //
29
+ // The fix is `nodeBinary()` from `utils/run-node.ts`: `process.execPath` when
30
+ // `isNode()`, PATH `node` otherwise.
31
+ //
32
+ // WHY A RULE AND NOT A COMMENT. This was already the documented convention —
33
+ // `oxc-resolve.ts`, `run-node.ts`, `test.ts`, `storybook.ts`, `showcase.ts` and
34
+ // `install.ts` each carry a prose warning naming `nodeBinary()`, two of them in
35
+ // capitals. Six copies of a rule and one file that never got it is the measured
36
+ // outcome of enforcing an invariant with comments; `commands/tsc.ts` was that
37
+ // file, for as long as the fallback existed.
38
+ //
39
+ // SCOPE — configured in `.oxlintrc.json` for `packages/infra/cli/src/**` only.
40
+ // Everywhere else `process.execPath` is correct: Node-only build scripts
41
+ // (`packages/infra/tsc/scripts/build-bundle.mjs`), Node-only test harnesses
42
+ // (`packages/node-gi/**/test/*`) and `run-node.ts` itself, which is where the
43
+ // one legitimate read lives. A repo-wide ban would flag all of those and would
44
+ // be a rule about the wrong thing: the hazard is not the property, it is
45
+ // spawning it from code that also runs under GJS.
46
+ //
47
+ // Flags only the COMMAND position (first argument). Passing the path as data —
48
+ // `spawn(cmd, [process.execPath])`, `{ execPath: process.execPath }` — is fine
49
+ // and stays silent. Not autofixable: `nodeBinary()` needs an import the rule
50
+ // cannot place, and it is the wrong repair where the intent really was "this
51
+ // interpreter" (`storybook.ts` re-dispatches in-process instead).
52
+ /** `child_process` entry points whose first argument is the command to run. */
53
+ const SPAWN_CALLEES = new Set(['spawn', 'spawnSync', 'execFile', 'execFileSync']);
54
+ /** Is this node the member expression `process.execPath`? */
55
+ function isProcessExecPath(node) {
56
+ if (!node || node.type !== 'MemberExpression' || node.computed === true)
57
+ return false;
58
+ const object = node.object;
59
+ const property = node.property;
60
+ return (object?.type === 'Identifier' &&
61
+ object.name === 'process' &&
62
+ property?.type === 'Identifier' &&
63
+ property.name === 'execPath');
64
+ }
65
+ /**
66
+ * The called name for `spawn(…)` and for `cp.spawn(…)` / `childProcess.spawn(…)`
67
+ * alike — the member form is how several call sites in this CLI spell it, and a
68
+ * rule that only understood the bare identifier would miss them.
69
+ */
70
+ function calleeName(callee) {
71
+ if (!callee)
72
+ return undefined;
73
+ if (callee.type === 'Identifier')
74
+ return callee.name;
75
+ if (callee.type === 'MemberExpression' && callee.computed !== true) {
76
+ const property = callee.property;
77
+ if (property?.type === 'Identifier')
78
+ return property.name;
79
+ }
80
+ return undefined;
81
+ }
82
+ export const spawnNodeBinaryRule = {
83
+ meta: {
84
+ // No autofix — see the note in the file header.
85
+ fixable: false,
86
+ },
87
+ create(context) {
88
+ return {
89
+ CallExpression(node) {
90
+ const name = calleeName(node.callee);
91
+ if (!name || !SPAWN_CALLEES.has(name))
92
+ return;
93
+ const args = node.arguments;
94
+ if (!Array.isArray(args) || args.length === 0)
95
+ return;
96
+ const command = args[0];
97
+ if (!isProcessExecPath(command))
98
+ return;
99
+ context.report({
100
+ message: `\`${name}(process.execPath, …)\` assumes this CLI runs under Node. Under the GJS ` +
101
+ 'bundle `process.execPath` is the GJS interpreter (e.g. `gjs-console`), ' +
102
+ 'so this starts the wrong runtime — a Node script handed to GJS runs and then dies ' +
103
+ 'inside the payload (`ReferenceError: module is not defined`), which reads as a bug ' +
104
+ 'in the payload rather than in the spawn. Use `nodeBinary()` from ' +
105
+ '`utils/run-node.ts` (`process.execPath` on Node, PATH `node` otherwise). If you ' +
106
+ 'genuinely mean "re-enter the CURRENT interpreter", say so in a reasoned ' +
107
+ 'oxlint-disable comment.',
108
+ node: command,
109
+ });
110
+ },
111
+ };
112
+ },
113
+ };
@@ -0,0 +1,2 @@
1
+ import type { Rule } from './types.ts';
2
+ export declare const todoNeedsAnchorRule: Rule;
@@ -0,0 +1,131 @@
1
+ // `gjsify/todo-needs-anchor` — a deferral marker must name where it is tracked.
2
+ //
3
+ // A bare `// TODO` has no owner, no date and no retirement path. Nothing fails
4
+ // when the work is done, nothing fails when it is abandoned, and it is invisible
5
+ // until somebody happens to open the file. Measured on this repo at v0.29.0: of
6
+ // 42 markers in source, 5 carried a reference and 37 did not — including
7
+ // `// TODO: Fix this test`, i.e. a test documented as broken with no way for
8
+ // anyone to notice it stayed that way.
9
+ //
10
+ // This is the ONE place the repo's own pattern was missing. Everywhere else a
11
+ // deferral must state where it lives and dies on its own:
12
+ //
13
+ // `it.failing(name, fn, reason)` runs the test, FAILS the day it passes
14
+ // `unchecked-fields.mjs` key → reason, FAILS when a rule claims it
15
+ // `gjsify.platformsUncommitted` target → reason, FAILS when the dir appears
16
+ // `// fixed upstream in gjsify: …` removed on the next version bump
17
+ //
18
+ // Each one is mandatory-reason plus self-retirement. A bare marker is neither.
19
+ //
20
+ // SCOPE — this rule does not ban deferral, it bans an UNTRACKED one. Three
21
+ // anchors are accepted, matching the three channels in AGENTS.md:
22
+ //
23
+ // `#123` a GitHub issue or PR — work needing discussion or a
24
+ // third party
25
+ // `open-todos` an entry under `status/open-todos/` (one file per
26
+ // area), which the `status-data` conformance rule
27
+ // already validates on every PR (it fails on
28
+ // resolved-TODO corpses)
29
+ // `fixed upstream in …` the cross-repo shim note, removed at the next bump
30
+ //
31
+ // The anchor must sit in the SAME comment as the marker: an anchor a reader has
32
+ // to go find is not evidence, and a block comment is one comment, so a `#123`
33
+ // anywhere in a JSDoc block covers a marker inside it.
34
+ //
35
+ // Comments are read through `sourceCode.getAllComments()` rather than by
36
+ // scanning the source text, so a marker inside a STRING is not a finding — the
37
+ // `--- outcomes:` style probe strings and this repo's own error messages contain
38
+ // the words. A text scan cannot tell a comment from a quoted one; that is the
39
+ // same distinction `gjs-bundle-guard.ts` documents for `node:` specifiers.
40
+ //
41
+ // Escape hatch is the ordinary lint one, and it carries a reason by convention:
42
+ // `// oxlint-disable-next-line gjsify/todo-needs-anchor -- <why>`. `.oxlintrc.json`
43
+ // already fails on a disable directive that suppresses nothing, so a stale
44
+ // exemption cannot sit there quietly either.
45
+ //
46
+ // Reference: AGENTS.md § Deferrals must name where they are tracked.
47
+ /**
48
+ * A deferral marker OPENS its line — `// TODO wire this up`, or the same after
49
+ * a block comment's leading `*`. Matched as a whole word so `TODOS` and an
50
+ * identifier like `todoCount` are not findings, and case-sensitively, because
51
+ * the convention is upper-case.
52
+ *
53
+ * Line-start is what separates a marker from PROSE ABOUT one. This file, the
54
+ * `status-data` conformance rule and `generate-status.mjs` all discuss TODOs in
55
+ * sentences ("a bare `// TODO` has no owner"), and flagging those would make the
56
+ * rule unable to document itself — measured: 5 of the first 31 findings were
57
+ * exactly that, three of them in this file. The cost is a mid-sentence deferral
58
+ * ("handle the empty case. TODO") going unseen; that shape is rare, and the
59
+ * false positives it would buy are not worth it.
60
+ */
61
+ const MARKER = /^(TODO|FIXME|HACK|XXX)\b/;
62
+ /** Strip a block comment's leading `*` and indentation from one line. */
63
+ function openingWord(line) {
64
+ return line.replace(/^\s*\*?\s*/, '');
65
+ }
66
+ /**
67
+ * An anchor that makes the deferral trackable. `#123` covers this repo's issues
68
+ * and PRs; a forge issue URL covers an UPSTREAM defect (GNOME GitLab, another
69
+ * GitHub repo) that no local number can name; `open-todos` covers the ledger
70
+ * under `status/`; `fixed upstream in` covers the temporary shim note.
71
+ *
72
+ * The URL form is not optional politeness — `error-handler.spec.ts` cites
73
+ * https://gitlab.gnome.org/GNOME/gjs/-/issues/523 for an object-identity gap,
74
+ * which is BETTER tracked than a local issue would be. A rule that flagged it
75
+ * would be teaching people to delete the most useful reference in the file.
76
+ */
77
+ const ANCHOR = /#\d+|\/(?:issues|pull|merge_requests)\/\d+|open-todos|fixed upstream in/i;
78
+ export const todoNeedsAnchorRule = {
79
+ meta: {
80
+ // No autofix — only a human knows whether the right repair is fixing it
81
+ // now, filing an issue, or adding a ledger entry.
82
+ fixable: false,
83
+ },
84
+ create(context) {
85
+ // `getAllComments` is not in the local typings (they model only what the
86
+ // other two rules need) and is absent on older oxlint hosts. Degrade to
87
+ // a no-op rather than throwing: a lint rule that crashes the run is
88
+ // worse than one that reports nothing.
89
+ const source = context.sourceCode;
90
+ if (typeof source.getAllComments !== 'function')
91
+ return {};
92
+ let checked = false;
93
+ const check = () => {
94
+ if (checked)
95
+ return;
96
+ checked = true;
97
+ for (const comment of source.getAllComments()) {
98
+ if (comment.type === 'Shebang')
99
+ continue;
100
+ // The anchor may sit anywhere in the comment — a `#123` in a
101
+ // JSDoc block covers a marker inside it — so it is tested
102
+ // against the whole value, the marker only per line.
103
+ if (ANCHOR.test(comment.value))
104
+ continue;
105
+ let marker = null;
106
+ for (const line of comment.value.split('\n')) {
107
+ marker = MARKER.exec(openingWord(line));
108
+ if (marker)
109
+ break;
110
+ }
111
+ if (!marker)
112
+ continue;
113
+ context.report({
114
+ message: `Untracked \`${marker[1]}\`. A deferral marker must name where it is tracked, or ` +
115
+ 'nothing fails when the work is finished and nothing fails when it is dropped. ' +
116
+ 'Pick the channel: fix it in THIS PR (the root-cause rule — preferred, and the ' +
117
+ 'reason this codebase has few of these); add an entry under `status/open-todos/` ' +
118
+ 'and cite it here, which the `status-data` conformance rule then validates every ' +
119
+ 'PR; or open an issue and cite `#<number>` when the work needs discussion or ' +
120
+ 'somebody else. A cross-repo shim writes `// fixed upstream in gjsify: <one-line>` ' +
121
+ 'instead. If the marker is prose rather than a deferral, rewrite it — the word is ' +
122
+ 'the signal.',
123
+ node: comment,
124
+ });
125
+ }
126
+ };
127
+ // Comments are file-scoped, so one pass per file. `Program` fires once
128
+ // and is present in every parse, including an empty file.
129
+ return { Program: check };
130
+ },
131
+ };
package/lib/types.d.ts ADDED
@@ -0,0 +1,78 @@
1
+ /** Half-open byte range `[start, end)` over the source text. */
2
+ export type Range = [number, number];
3
+ /** Anything the fixer can target — a node or token with a `range`. */
4
+ export interface Ranged {
5
+ range: Range;
6
+ }
7
+ /** Generic AST node. Only the fields the rule touches are modelled. */
8
+ export interface Node extends Ranged {
9
+ type: string;
10
+ start: number;
11
+ end: number;
12
+ parent?: Node;
13
+ [key: string]: unknown;
14
+ }
15
+ export interface ClassBody extends Node {
16
+ type: 'ClassBody';
17
+ body: ClassElement[];
18
+ }
19
+ export interface ClassElement extends Node {
20
+ type: string;
21
+ static?: boolean;
22
+ }
23
+ export interface PropertyDefinition extends Node {
24
+ type: 'PropertyDefinition' | 'TSAbstractPropertyDefinition';
25
+ static: boolean;
26
+ computed: boolean;
27
+ key: Node;
28
+ value: Node | null;
29
+ }
30
+ export interface StaticBlock extends Node {
31
+ type: 'StaticBlock';
32
+ body: Node[];
33
+ }
34
+ /** A single fix produced by the {@link Fixer}. */
35
+ export interface Fix {
36
+ range: Range;
37
+ text: string;
38
+ }
39
+ /** Frozen fixer handed to a `fix` function. Subset of oxlint's full Fixer. */
40
+ export interface Fixer {
41
+ insertTextBefore(nodeOrToken: Ranged, text: string): Fix;
42
+ insertTextAfter(nodeOrToken: Ranged, text: string): Fix;
43
+ replaceText(nodeOrToken: Ranged, text: string): Fix;
44
+ remove(nodeOrToken: Ranged): Fix;
45
+ insertTextBeforeRange(range: Range, text: string): Fix;
46
+ removeRange(range: Range): Fix;
47
+ }
48
+ export type FixFn = (fixer: Fixer) => Fix | Fix[] | null;
49
+ export interface Diagnostic {
50
+ message: string;
51
+ node: Node;
52
+ fix?: FixFn;
53
+ }
54
+ export interface SourceCode {
55
+ text: string;
56
+ getText(node?: Ranged | null, beforeCount?: number | null, afterCount?: number | null): string;
57
+ }
58
+ export interface Context {
59
+ filename: string;
60
+ sourceCode: SourceCode;
61
+ report(diagnostic: Diagnostic): void;
62
+ }
63
+ /** Visitor object: maps AST node type → handler. */
64
+ export type Visitor = Record<string, (node: Node) => void>;
65
+ export interface RuleMeta {
66
+ fixable?: 'code' | 'whitespace' | boolean;
67
+ [key: string]: unknown;
68
+ }
69
+ export interface Rule {
70
+ meta?: RuleMeta;
71
+ create(context: Context): Visitor;
72
+ }
73
+ export interface Plugin {
74
+ meta: {
75
+ name: string;
76
+ };
77
+ rules: Record<string, Rule>;
78
+ }
package/lib/types.js ADDED
@@ -0,0 +1,10 @@
1
+ // Minimal local typings for the subset of the oxlint JS-plugin API this
2
+ // plugin uses. The published `oxlint` package (1.66/1.67) does not export the
3
+ // `Plugin` / `Rule` / `Context` / `Fixer` types yet, so we declare just what
4
+ // `register-class-order` needs. Shapes mirror
5
+ // `refs/oxc/apps/oxlint/src-js/plugins/{load,context,fix,report}.ts` and the
6
+ // ESTree nodes in `refs/oxc/apps/oxlint/src-js/generated/types.d.ts`.
7
+ //
8
+ // Reference: refs/oxc/apps/oxlint/src-js/plugins/*. Copyright (c) oxc
9
+ // contributors. MIT.
10
+ export {};
package/lib/walk.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { Node } from './types.ts';
2
+ /**
3
+ * True for a plain object that carries the shape of an AST node.
4
+ *
5
+ * Exported because a rule that needs its OWN traversal state (a scope stack, say) cannot express
6
+ * it through {@link walk}'s flat visitor, and re-deriving this test per rule is how two answers to
7
+ * "is this a node" drift apart. The structural caveat in {@link walk} applies to every caller.
8
+ */
9
+ export declare function isNode(value: unknown): value is Node;
10
+ /**
11
+ * Depth-first walk over every node in `root`'s subtree, `root` included.
12
+ *
13
+ * THREE keys are skipped, for two different reasons. `parent`, because where the host populates it,
14
+ * following it walks back out of the subtree and loops forever. `tokens` and `comments`, because
15
+ * oxlint hangs both off the Program node and `isNode` is a STRUCTURAL sniff — a token object has a
16
+ * `type` string, so it looks exactly like a node. Measured: walking them accounted for the large
17
+ * majority of these rules' wall-clock and visited nothing either rule can act on.
18
+ *
19
+ * That structural sniff is the general hazard: anything with a string `type` is followed. Hand this
20
+ * function the smallest root that contains what you need, not the Program node.
21
+ */
22
+ export declare function walk(root: Node, visit: (node: Node) => void): void;
23
+ /** `new Gtk.Label(…)` / `new Adw.ActionRow(…)` → `"Gtk.Label"` / `"Adw.ActionRow"`; else null. */
24
+ export declare function newGtkAdwType(node: Node): string | null;
25
+ /** The method name of `x.foo(…)`, or null for anything that is not a plain member call. */
26
+ export declare function memberCallName(node: Node): string | null;
package/lib/walk.js ADDED
@@ -0,0 +1,73 @@
1
+ // Shared subtree walker for the class-scoped rules.
2
+ //
3
+ // oxlint's JS-plugin visitor dispatches per node TYPE, which is enough for a rule that only
4
+ // inspects a class's DIRECT children (`register-class-order`). A rule that asks "does this class
5
+ // build widgets anywhere inside it" needs the whole subtree, and `Node.parent` is not something
6
+ // the plugin host guarantees to populate — so the traversal is owned here rather than inferred
7
+ // from a parent chain that may not exist.
8
+ /**
9
+ * True for a plain object that carries the shape of an AST node.
10
+ *
11
+ * Exported because a rule that needs its OWN traversal state (a scope stack, say) cannot express
12
+ * it through {@link walk}'s flat visitor, and re-deriving this test per rule is how two answers to
13
+ * "is this a node" drift apart. The structural caveat in {@link walk} applies to every caller.
14
+ */
15
+ export function isNode(value) {
16
+ return typeof value === 'object' && value !== null && typeof value.type === 'string';
17
+ }
18
+ /**
19
+ * Depth-first walk over every node in `root`'s subtree, `root` included.
20
+ *
21
+ * THREE keys are skipped, for two different reasons. `parent`, because where the host populates it,
22
+ * following it walks back out of the subtree and loops forever. `tokens` and `comments`, because
23
+ * oxlint hangs both off the Program node and `isNode` is a STRUCTURAL sniff — a token object has a
24
+ * `type` string, so it looks exactly like a node. Measured: walking them accounted for the large
25
+ * majority of these rules' wall-clock and visited nothing either rule can act on.
26
+ *
27
+ * That structural sniff is the general hazard: anything with a string `type` is followed. Hand this
28
+ * function the smallest root that contains what you need, not the Program node.
29
+ */
30
+ export function walk(root, visit) {
31
+ visit(root);
32
+ for (const key of Object.keys(root)) {
33
+ if (key === 'parent' || key === 'tokens' || key === 'comments')
34
+ continue;
35
+ const value = root[key];
36
+ if (Array.isArray(value)) {
37
+ for (const item of value)
38
+ if (isNode(item))
39
+ walk(item, visit);
40
+ }
41
+ else if (isNode(value)) {
42
+ walk(value, visit);
43
+ }
44
+ }
45
+ }
46
+ /** `new Gtk.Label(…)` / `new Adw.ActionRow(…)` → `"Gtk.Label"` / `"Adw.ActionRow"`; else null. */
47
+ export function newGtkAdwType(node) {
48
+ if (node.type !== 'NewExpression')
49
+ return null;
50
+ const callee = node.callee;
51
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed === true)
52
+ return null;
53
+ const object = callee.object;
54
+ const property = callee.property;
55
+ if (object?.type !== 'Identifier' || property?.type !== 'Identifier')
56
+ return null;
57
+ const ns = object.name;
58
+ if (ns !== 'Gtk' && ns !== 'Adw')
59
+ return null;
60
+ return `${ns}.${property.name}`;
61
+ }
62
+ /** The method name of `x.foo(…)`, or null for anything that is not a plain member call. */
63
+ export function memberCallName(node) {
64
+ if (node.type !== 'CallExpression')
65
+ return null;
66
+ const callee = node.callee;
67
+ if (!callee || callee.type !== 'MemberExpression' || callee.computed === true)
68
+ return null;
69
+ const property = callee.property;
70
+ if (property?.type !== 'Identifier')
71
+ return null;
72
+ return property.name;
73
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@gjsify/oxlint-plugin-gjsify",
3
+ "version": "0.53.0",
4
+ "description": "oxlint JS plugin with gjsify's own lint rules for GJS/GTK code — Blueprint templates, translatable widget labels, GObject.registerClass field order, GJS-correct process exit, anchored deferral markers, and the CSS side-effect import that tree-shakes away. Wire it through `jsPlugins` in `.oxlintrc.json`.",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "module": "lib/index.js",
8
+ "types": "lib/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./lib/index.d.ts",
12
+ "default": "./lib/index.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib"
18
+ ],
19
+ "scripts": {
20
+ "clear": "gjsify clear lib tsconfig.tsbuildinfo",
21
+ "check": "gjsify tsc --noEmit",
22
+ "build": "gjsify tsc -p tsconfig.build.json"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/gjsify/gjsify.git",
27
+ "directory": "packages/infra/oxlint-plugin-gjsify"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/gjsify/gjsify/issues"
31
+ },
32
+ "homepage": "https://github.com/gjsify/gjsify/tree/main/packages/infra/oxlint-plugin-gjsify#readme",
33
+ "keywords": [
34
+ "oxlint",
35
+ "oxc",
36
+ "plugin",
37
+ "gjs",
38
+ "gtk",
39
+ "blueprint",
40
+ "gobject",
41
+ "lint"
42
+ ],
43
+ "license": "MIT",
44
+ "peerDependencies": {
45
+ "oxlint": "^1.72.0"
46
+ },
47
+ "devDependencies": {
48
+ "@gjsify/cli": "^0.53.0",
49
+ "@types/node": "^25.9.2",
50
+ "typescript": "^6.0.3"
51
+ },
52
+ "gjsify": {
53
+ "tier": 1
54
+ }
55
+ }