@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
|
@@ -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,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,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
|
+
}
|