@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,190 @@
|
|
|
1
|
+
// `gjsify/no-literal-widget-label` — user-visible text hard-coded into a widget from TypeScript.
|
|
2
|
+
//
|
|
3
|
+
// This is the half of the Blueprint problem that survives even after a class HAS a template: a
|
|
4
|
+
// label handed to a constructor or a setter as a bare string literal. It is not a style question.
|
|
5
|
+
// `xgettext` collects `translatable="yes"` out of Blueprint and `_("…")` out of TypeScript, and a
|
|
6
|
+
// bare literal is neither — so the string is invisible to extraction, absent from every catalogue,
|
|
7
|
+
// and shows up in the user's language only by accident. The failure is silent in the worst way:
|
|
8
|
+
// the interface looks untranslated rather than untranslatABLE, so it never gets filed.
|
|
9
|
+
//
|
|
10
|
+
// Two repairs, and the rule accepts both because the right one depends on the string:
|
|
11
|
+
// · move it into the co-located `.blp` as `title: _("…")` — correct for anything static;
|
|
12
|
+
// · wrap it here as `_("…")` — correct for a string a runtime value picks.
|
|
13
|
+
// Either turns the literal into something extraction can see, which is why wrapping silences it.
|
|
14
|
+
//
|
|
15
|
+
// Only PROSE positions are checked. `icon-name`, `css-classes`, `action-name`, a stack page's
|
|
16
|
+
// `name` and an `Adw.EntryRow`'s `text` (which carries user data, not a caption) are deliberately
|
|
17
|
+
// absent: a false finding on those would teach people to disable the rule.
|
|
18
|
+
//
|
|
19
|
+
// The setter half matches on METHOD NAME alone — the rule cannot see whether the receiver is a
|
|
20
|
+
// widget — so a non-GTK object with a `set_title` method is reported too. That is the deliberate
|
|
21
|
+
// trade: the alternative is missing every setter call on a variable whose type the linter has no
|
|
22
|
+
// access to, and `_()` around a genuinely non-UI string costs a catalogue entry and nothing else.
|
|
23
|
+
import { walk } from "./walk.js";
|
|
24
|
+
/** Constructor-object keys whose value is shown to a person. Both GJS spellings. */
|
|
25
|
+
const PROSE_PROPERTIES = new Set([
|
|
26
|
+
'label',
|
|
27
|
+
'title',
|
|
28
|
+
'subtitle',
|
|
29
|
+
'description',
|
|
30
|
+
'heading',
|
|
31
|
+
'body',
|
|
32
|
+
'tooltipText',
|
|
33
|
+
'tooltip_text',
|
|
34
|
+
'placeholderText',
|
|
35
|
+
'placeholder_text',
|
|
36
|
+
'secondaryText',
|
|
37
|
+
'secondary_text',
|
|
38
|
+
]);
|
|
39
|
+
/**
|
|
40
|
+
* Setters that write the same prose positions, mapped to the PROPERTY they set — so the repair the
|
|
41
|
+
* message suggests is one Blueprint can actually express. `set_title: _("…")` is not a Blueprint
|
|
42
|
+
* property; `title` is.
|
|
43
|
+
*/
|
|
44
|
+
const PROSE_SETTERS = new Map([
|
|
45
|
+
['set_label', 'label'],
|
|
46
|
+
['set_title', 'title'],
|
|
47
|
+
['set_subtitle', 'subtitle'],
|
|
48
|
+
['set_description', 'description'],
|
|
49
|
+
['set_heading', 'heading'],
|
|
50
|
+
['set_body', 'body'],
|
|
51
|
+
['set_tooltip_text', 'tooltip-text'],
|
|
52
|
+
['set_placeholder_text', 'placeholder-text'],
|
|
53
|
+
]);
|
|
54
|
+
/**
|
|
55
|
+
* Prose that arrives as a LATER argument, with the index it sits at.
|
|
56
|
+
*
|
|
57
|
+
* `Adw.AlertDialog` gets its button captions only through these, and nothing else covered them: a
|
|
58
|
+
* dialog whose `heading` this rule reports would still ship English-only buttons, in the very same
|
|
59
|
+
* function. Measured across the two consumer apps: 24 live `add_response(` call sites.
|
|
60
|
+
*
|
|
61
|
+
* The surface is small and closed — `Adw.AlertDialog` and `Adw.MessageDialog` are the only classes
|
|
62
|
+
* in `@girs/adw-1` that declare `set_response_label`.
|
|
63
|
+
*/
|
|
64
|
+
const PROSE_ARGUMENTS = new Map([
|
|
65
|
+
['add_response', 1],
|
|
66
|
+
['set_response_label', 1],
|
|
67
|
+
]);
|
|
68
|
+
/**
|
|
69
|
+
* Is this literal worth reporting?
|
|
70
|
+
*
|
|
71
|
+
* A caption needs letters. `""`, `"—"`, `"…"`, `"%"` and `"3"` are punctuation or data with
|
|
72
|
+
* nothing to translate, and reporting them is how a rule loses its audience.
|
|
73
|
+
*/
|
|
74
|
+
function isProse(value) {
|
|
75
|
+
return typeof value === 'string' && /\p{L}/u.test(value);
|
|
76
|
+
}
|
|
77
|
+
/** The string value of a plain literal (not a template, not a concatenation); else null. */
|
|
78
|
+
function literalString(node) {
|
|
79
|
+
if (!node)
|
|
80
|
+
return null;
|
|
81
|
+
if (node.type === 'Literal' && isProse(node.value))
|
|
82
|
+
return node.value;
|
|
83
|
+
// A no-substitution template literal is a literal in every way that matters here.
|
|
84
|
+
if (node.type === 'TemplateLiteral') {
|
|
85
|
+
const expressions = node.expressions;
|
|
86
|
+
const quasis = node.quasis;
|
|
87
|
+
if (expressions?.length === 0 && quasis?.length === 1) {
|
|
88
|
+
const cooked = quasis[0].value?.cooked;
|
|
89
|
+
if (isProse(cooked))
|
|
90
|
+
return cooked;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
/** `Gtk`/`Adw` namespaced callee — `new Adw.ActionRow(…)`. */
|
|
96
|
+
function isGtkAdwNew(node) {
|
|
97
|
+
if (node.type !== 'NewExpression')
|
|
98
|
+
return false;
|
|
99
|
+
const callee = node.callee;
|
|
100
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed === true)
|
|
101
|
+
return false;
|
|
102
|
+
const object = callee.object;
|
|
103
|
+
if (object?.type !== 'Identifier')
|
|
104
|
+
return false;
|
|
105
|
+
const ns = object.name;
|
|
106
|
+
return ns === 'Gtk' || ns === 'Adw';
|
|
107
|
+
}
|
|
108
|
+
export const noLiteralWidgetLabelRule = {
|
|
109
|
+
create(context) {
|
|
110
|
+
/** For a caption with no declarative form: the only repair is `_()` at the call site. */
|
|
111
|
+
const reportInPlace = (node, method, text) => {
|
|
112
|
+
const shown = text.length > 40 ? `${text.slice(0, 40)}…` : text;
|
|
113
|
+
context.report({
|
|
114
|
+
message: `\`${method}\` is given the literal "${shown}". Extraction sees neither a Blueprint ` +
|
|
115
|
+
`\`translatable\` attribute nor a \`_()\` call here, so this text can never reach a ` +
|
|
116
|
+
`catalogue. There is no Blueprint property for it — the caption is added by a call — ` +
|
|
117
|
+
`so wrap it: \`${method}(…, _("…"))\`.`,
|
|
118
|
+
node,
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
const report = (node, key, text) => {
|
|
122
|
+
const shown = text.length > 40 ? `${text.slice(0, 40)}…` : text;
|
|
123
|
+
context.report({
|
|
124
|
+
message: `\`${key}\` is set to the literal "${shown}". Extraction sees neither a Blueprint ` +
|
|
125
|
+
`\`translatable\` attribute nor a \`_()\` call here, so this text can never reach a ` +
|
|
126
|
+
`catalogue. Move it into the co-located .blp as \`${key}: _("…")\`, or wrap it as \`_("…")\` ` +
|
|
127
|
+
`if a runtime value picks it.`,
|
|
128
|
+
node,
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
Program(program) {
|
|
133
|
+
// The statements, NOT the Program node: oxlint hangs `tokens` and `comments` off
|
|
134
|
+
// Program, and `walk`'s node test is structural, so handing it Program walked the
|
|
135
|
+
// whole token stream — the large majority of this rule's cost, for nothing.
|
|
136
|
+
const visit = (node) => {
|
|
137
|
+
// new Gtk.X({ title: "…" })
|
|
138
|
+
if (isGtkAdwNew(node)) {
|
|
139
|
+
for (const arg of node.arguments ?? []) {
|
|
140
|
+
if (arg.type !== 'ObjectExpression')
|
|
141
|
+
continue;
|
|
142
|
+
for (const prop of arg.properties ?? []) {
|
|
143
|
+
if (prop.type !== 'Property' || prop.computed === true)
|
|
144
|
+
continue;
|
|
145
|
+
const key = prop.key;
|
|
146
|
+
if (key?.type !== 'Identifier')
|
|
147
|
+
continue;
|
|
148
|
+
const name = key.name;
|
|
149
|
+
if (!PROSE_PROPERTIES.has(name))
|
|
150
|
+
continue;
|
|
151
|
+
const text = literalString(prop.value);
|
|
152
|
+
if (text !== null)
|
|
153
|
+
report(prop, name, text);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// widget.set_title("…") / dialog.add_response("cancel", "…")
|
|
159
|
+
if (node.type === 'CallExpression') {
|
|
160
|
+
const callee = node.callee;
|
|
161
|
+
if (!callee || callee.type !== 'MemberExpression' || callee.computed === true)
|
|
162
|
+
return;
|
|
163
|
+
const property = callee.property;
|
|
164
|
+
if (property?.type !== 'Identifier')
|
|
165
|
+
return;
|
|
166
|
+
const name = property.name;
|
|
167
|
+
const args = node.arguments ?? [];
|
|
168
|
+
const setterProperty = PROSE_SETTERS.get(name);
|
|
169
|
+
if (setterProperty !== undefined) {
|
|
170
|
+
const text = literalString(args[0]);
|
|
171
|
+
if (text !== null)
|
|
172
|
+
report(node, setterProperty, text);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const index = PROSE_ARGUMENTS.get(name);
|
|
176
|
+
if (index !== undefined) {
|
|
177
|
+
const text = literalString(args[index]);
|
|
178
|
+
// No Blueprint property to name here — a response label is ADDED by a
|
|
179
|
+
// call, not declared — so the message says to wrap it in place.
|
|
180
|
+
if (text !== null)
|
|
181
|
+
reportInPlace(node, name, text);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
for (const statement of program.body ?? [])
|
|
186
|
+
walk(statement, visit);
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
},
|
|
190
|
+
};
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// `gjsify/prefer-blueprint-template` — code that BUILDS a widget tree in TypeScript instead of
|
|
2
|
+
// declaring it in a Blueprint template.
|
|
3
|
+
//
|
|
4
|
+
// NOT ONLY A CLASS. The rule shipped visiting `ClassDeclaration`/`ClassExpression` only, and the
|
|
5
|
+
// one interface in this repository that every new user inherits was assembled somewhere else:
|
|
6
|
+
// `templates/gtk-minimal/src/index.ts` built its window, box and two labels inside the
|
|
7
|
+
// `activate` callback, with a literal caption no extraction can see, and reported ZERO — a green
|
|
8
|
+
// that meant "did not look". So the rule now also walks module scope (top-level statements,
|
|
9
|
+
// callbacks, factory functions) and reports the DEEPEST function that holds both signals.
|
|
10
|
+
//
|
|
11
|
+
// The incident this rule exists for is structural rather than a crash, which is why nothing else
|
|
12
|
+
// catches it. Measured across this workspace in 2026-08:
|
|
13
|
+
//
|
|
14
|
+
// Learn6502 (the reference app) 24 .blp 8 programmatic `new Gtk/Adw.X`
|
|
15
|
+
// bauplaner 0 .blp 422
|
|
16
|
+
// steuererklaerung 15 .blp 895 — and those 15 are empty scaffolds
|
|
17
|
+
//
|
|
18
|
+
// Learn6502 is proof that a whole application's interface fits in Blueprint. The other two grew
|
|
19
|
+
// the opposite way, one `append()` at a time, and the cost only shows up later and all at once:
|
|
20
|
+
//
|
|
21
|
+
// · A string built into a widget from TypeScript cannot be marked `translatable`, so xgettext
|
|
22
|
+
// never sees it. The interface is then untranslatable by construction, and it looks exactly
|
|
23
|
+
// like an interface nobody has translated yet.
|
|
24
|
+
// · `bind` and `notify` wiring has to be hand-written, so state and widget drift apart.
|
|
25
|
+
// · There is no artifact a designer can read or a screenshot tool can diff against.
|
|
26
|
+
//
|
|
27
|
+
// WHAT IS NOT A FINDING. A template that populates DATA-DRIVEN children at runtime is the
|
|
28
|
+
// intended pattern, not a violation — declare the container in Blueprint, fill it in TypeScript.
|
|
29
|
+
// Such a class has a `Template`, so it is silent here. So is a class that constructs no widget at
|
|
30
|
+
// all (a model, a controller, a `vfunc_snapshot` drawing widget), and so is one that constructs
|
|
31
|
+
// widgets but never parents any — the rule requires BOTH signals before it says anything, because
|
|
32
|
+
// either one alone is ordinary code.
|
|
33
|
+
//
|
|
34
|
+
// Library code that implements widgets for others (a renderer, a host, a storybook harness) is
|
|
35
|
+
// exempt by nature; scope the rule to application packages in `.oxlintrc.json` rather than
|
|
36
|
+
// disabling it line by line. A site blocked by the TOOLCHAIN rather than by its nature is the
|
|
37
|
+
// other case and keeps a line-level disable with its measurement, so the blocker is still
|
|
38
|
+
// visible where it applies and still deletable when it lifts.
|
|
39
|
+
//
|
|
40
|
+
// WHAT STILL ESCAPES, measured with fixtures rather than assumed: a class that already declares a
|
|
41
|
+
// `Template` and assembles a SECOND tree in one of its methods. That is the price of the promise
|
|
42
|
+
// two paragraphs up — a templated class is silent — and it is bounded, because such a class has
|
|
43
|
+
// already declared most of its interface. Everything else that was tried is caught: construction
|
|
44
|
+
// and parenting split across two sibling functions, a factory that returns a widget, assembly in
|
|
45
|
+
// a top-level statement, a method of a plain class, an object-literal method, an arrow stored in
|
|
46
|
+
// a const, `.tsx`, and a `vfunc_activate()` inside an application subclass.
|
|
47
|
+
import { isNode, memberCallName, newGtkAdwType, walk } from "./walk.js";
|
|
48
|
+
/**
|
|
49
|
+
* Methods that take a WIDGET and place it inside another one. Membership here is what separates
|
|
50
|
+
* "constructs a widget" from "assembles an interface" — the second is the finding.
|
|
51
|
+
*/
|
|
52
|
+
const PARENTING_METHODS = new Set([
|
|
53
|
+
'set_child',
|
|
54
|
+
'append',
|
|
55
|
+
'prepend',
|
|
56
|
+
'insert_child_after',
|
|
57
|
+
'add',
|
|
58
|
+
'add_row',
|
|
59
|
+
'add_prefix',
|
|
60
|
+
'add_suffix',
|
|
61
|
+
'add_child',
|
|
62
|
+
'add_overlay',
|
|
63
|
+
'add_titled',
|
|
64
|
+
'add_named',
|
|
65
|
+
'add_page',
|
|
66
|
+
'add_top_bar',
|
|
67
|
+
'add_bottom_bar',
|
|
68
|
+
'add_toolbar',
|
|
69
|
+
'set_content',
|
|
70
|
+
'set_title_widget',
|
|
71
|
+
'set_start_widget',
|
|
72
|
+
'set_end_widget',
|
|
73
|
+
'set_center_widget',
|
|
74
|
+
'set_header_suffix',
|
|
75
|
+
'set_extra_child',
|
|
76
|
+
'attach',
|
|
77
|
+
]);
|
|
78
|
+
/**
|
|
79
|
+
* Constructions that are NOT widgets, so building one is never "assembling an interface".
|
|
80
|
+
*
|
|
81
|
+
* The rule needs both its signals to be about widgets, and it cannot type-check: it sees
|
|
82
|
+
* `new Gtk.X` and a method NAME, never a GType. Most non-widgets are harmless because their own
|
|
83
|
+
* API shares no name with a parenting method — `new Gtk.GestureClick()` + `add_controller` is
|
|
84
|
+
* already silent. The ones that DO collide are listed here, `Gtk.StringList` being the case that
|
|
85
|
+
* actually occurs: a combo row filling a model with `.append(<string>)` was reported as assembling
|
|
86
|
+
* an interface, which is exactly the model case the header promises is silent.
|
|
87
|
+
*
|
|
88
|
+
* Families are matched by prefix below rather than enumerated, because their members are many and
|
|
89
|
+
* the list would drift against the GIR.
|
|
90
|
+
*/
|
|
91
|
+
const NON_WIDGET_CONSTRUCTIONS = new Set([
|
|
92
|
+
'Gtk.Adjustment',
|
|
93
|
+
'Gtk.AnyFilter',
|
|
94
|
+
'Gtk.BoolFilter',
|
|
95
|
+
'Gtk.Builder',
|
|
96
|
+
'Gtk.BuilderListItemFactory',
|
|
97
|
+
'Gtk.CssProvider',
|
|
98
|
+
'Gtk.CustomFilter',
|
|
99
|
+
'Gtk.CustomSorter',
|
|
100
|
+
'Gtk.EntryBuffer',
|
|
101
|
+
'Gtk.EveryFilter',
|
|
102
|
+
'Gtk.FileDialog',
|
|
103
|
+
'Gtk.FileFilter',
|
|
104
|
+
'Gtk.ListStore',
|
|
105
|
+
'Gtk.NumericSorter',
|
|
106
|
+
'Gtk.SignalListItemFactory',
|
|
107
|
+
'Gtk.SizeGroup',
|
|
108
|
+
'Gtk.StringFilter',
|
|
109
|
+
'Gtk.StringList',
|
|
110
|
+
'Gtk.StringObject',
|
|
111
|
+
'Gtk.StringSorter',
|
|
112
|
+
'Gtk.TextBuffer',
|
|
113
|
+
'Gtk.TextTag',
|
|
114
|
+
'Gtk.TextTagTable',
|
|
115
|
+
'Adw.Breakpoint',
|
|
116
|
+
'Adw.BreakpointCondition',
|
|
117
|
+
'Adw.CallbackAnimationTarget',
|
|
118
|
+
'Adw.EnumListModel',
|
|
119
|
+
'Adw.PropertyAnimationTarget',
|
|
120
|
+
'Adw.SpringAnimation',
|
|
121
|
+
'Adw.SpringParams',
|
|
122
|
+
'Adw.StyleManager',
|
|
123
|
+
'Adw.TimedAnimation',
|
|
124
|
+
'Adw.Toast',
|
|
125
|
+
// An application object is not a widget, and every scaffold constructs one at module scope.
|
|
126
|
+
// Listed here (not only in `NON_WIDGET_BASES`, which answers a different question) so that
|
|
127
|
+
// `new Adw.Application(…)` is not the construction half of a module-scope finding.
|
|
128
|
+
'Gtk.Application',
|
|
129
|
+
'Adw.Application',
|
|
130
|
+
]);
|
|
131
|
+
/** Prefixes whose whole family is non-widget: controllers, gestures and list models. */
|
|
132
|
+
const NON_WIDGET_PREFIXES = ['Gtk.EventController', 'Gtk.Gesture', 'Gtk.MultiSorter'];
|
|
133
|
+
/** Suffixes covering the list-model and selection families without enumerating them. */
|
|
134
|
+
const NON_WIDGET_SUFFIXES = ['ListModel', 'Selection'];
|
|
135
|
+
function isNonWidgetConstruction(type) {
|
|
136
|
+
if (NON_WIDGET_CONSTRUCTIONS.has(type))
|
|
137
|
+
return true;
|
|
138
|
+
if (NON_WIDGET_PREFIXES.some((prefix) => type.startsWith(prefix)))
|
|
139
|
+
return true;
|
|
140
|
+
return NON_WIDGET_SUFFIXES.some((suffix) => type.endsWith(suffix));
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Bases that are NOT widgets, so there is no interface for a template to hold. `Gtk.Application` /
|
|
144
|
+
* `Adw.Application` are the ones that actually occur: an application object legitimately builds a
|
|
145
|
+
* `Gtk.CssProvider` and an `Adw.AboutDialog` from its own metadata, and reporting that taught the
|
|
146
|
+
* rule's first reader that it cries wolf.
|
|
147
|
+
*/
|
|
148
|
+
const NON_WIDGET_BASES = new Set(['Gtk.Application', 'Adw.Application']);
|
|
149
|
+
/** `class X extends Gtk.Widget` / `extends Adw.Bin` → the base name; null for anything else. */
|
|
150
|
+
function gtkAdwSuperClass(node) {
|
|
151
|
+
const superClass = node.superClass;
|
|
152
|
+
if (!superClass || superClass.type !== 'MemberExpression' || superClass.computed === true)
|
|
153
|
+
return null;
|
|
154
|
+
const object = superClass.object;
|
|
155
|
+
const property = superClass.property;
|
|
156
|
+
if (object?.type !== 'Identifier' || property?.type !== 'Identifier')
|
|
157
|
+
return null;
|
|
158
|
+
const ns = object.name;
|
|
159
|
+
if (ns !== 'Gtk' && ns !== 'Adw')
|
|
160
|
+
return null;
|
|
161
|
+
const base = `${ns}.${property.name}`;
|
|
162
|
+
return NON_WIDGET_BASES.has(base) ? null : base;
|
|
163
|
+
}
|
|
164
|
+
/** A `Template` key directly on this object literal — not nested anywhere inside it. */
|
|
165
|
+
function objectDeclaresTemplate(node) {
|
|
166
|
+
if (node.type !== 'ObjectExpression')
|
|
167
|
+
return false;
|
|
168
|
+
for (const prop of node.properties ?? []) {
|
|
169
|
+
if (prop.type !== 'Property' || prop.computed === true)
|
|
170
|
+
continue;
|
|
171
|
+
const key = prop.key;
|
|
172
|
+
if (key?.type === 'Identifier' && key.name === 'Template')
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
/** A `registerClass(…)` call inside this static block whose metadata object carries `Template`. */
|
|
178
|
+
function staticBlockDeclaresTemplate(block) {
|
|
179
|
+
let found = false;
|
|
180
|
+
walk(block, (node) => {
|
|
181
|
+
if (found || node.type !== 'CallExpression')
|
|
182
|
+
return;
|
|
183
|
+
const callee = node.callee;
|
|
184
|
+
const named = (callee?.type === 'MemberExpression' &&
|
|
185
|
+
callee.computed !== true &&
|
|
186
|
+
callee.property?.type === 'Identifier' &&
|
|
187
|
+
callee.property.name === 'registerClass') ||
|
|
188
|
+
(callee?.type === 'Identifier' && callee.name === 'registerClass');
|
|
189
|
+
if (!named)
|
|
190
|
+
return;
|
|
191
|
+
for (const arg of node.arguments ?? []) {
|
|
192
|
+
if (objectDeclaresTemplate(arg))
|
|
193
|
+
found = true;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
return found;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* True when THIS class body declares a `Template`: a `static Template` field of its own, or a
|
|
200
|
+
* `registerClass` metadata object inside its own `static {}` block, which is this repo's
|
|
201
|
+
* convention (see the sibling `gjsify/register-class-order`).
|
|
202
|
+
*
|
|
203
|
+
* Deliberately NOT a subtree search. An unbounded walk for the identifier `Template` was silenced
|
|
204
|
+
* by things that declare nothing — a data literal that merely uses the word (`{ Template: false }`,
|
|
205
|
+
* `{ columns: [{ Template: 'row.ui' }] }`), and a NESTED helper class carrying the only real
|
|
206
|
+
* template. The rule's whole value is that it cannot be switched off by accident, so an accidental
|
|
207
|
+
* silencer is worse here than a missed case.
|
|
208
|
+
*
|
|
209
|
+
* A class defined INSIDE the static block still counts, which is pathological enough to leave.
|
|
210
|
+
*/
|
|
211
|
+
function declaresTemplate(body) {
|
|
212
|
+
for (const element of body.body) {
|
|
213
|
+
if (element.type === 'PropertyDefinition' || element.type === 'TSAbstractPropertyDefinition') {
|
|
214
|
+
const field = element;
|
|
215
|
+
const key = field.key;
|
|
216
|
+
if (field.static && !field.computed && key.type === 'Identifier' && key.name === 'Template') {
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (element.type === 'StaticBlock' && staticBlockDeclaresTemplate(element))
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
/** The nodes that own a scope here: a function is the unit a reader can move into a `.blp`. */
|
|
227
|
+
const SCOPE_TYPES = new Set(['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression']);
|
|
228
|
+
/**
|
|
229
|
+
* How a scope is named in its own report. A class gets to be named by its base; a function has no
|
|
230
|
+
* base, so the message names the SHAPE the reader is looking at instead of saying "somewhere".
|
|
231
|
+
*/
|
|
232
|
+
function describeScope(node) {
|
|
233
|
+
if (node.type === 'Program')
|
|
234
|
+
return 'This module assembles an interface at top level';
|
|
235
|
+
if (node.type === 'FunctionDeclaration') {
|
|
236
|
+
const id = node.id;
|
|
237
|
+
const name = id?.type === 'Identifier' ? id.name : null;
|
|
238
|
+
return name === null ? 'This function assembles an interface' : `\`${name}()\` assembles an interface`;
|
|
239
|
+
}
|
|
240
|
+
return 'This callback assembles an interface';
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Depth-first descent that attributes every construction and every parenting call to the nearest
|
|
244
|
+
* enclosing function, and refuses to enter a Gtk/Adw class body.
|
|
245
|
+
*
|
|
246
|
+
* It cannot be {@link walk}: walk's visitor is flat, and this needs a scope that is pushed on the
|
|
247
|
+
* way down and popped on the way out. The three skipped keys are walk's, for walk's reasons
|
|
248
|
+
* (`parent` loops; `tokens`/`comments` hang off Program and pass the structural node test).
|
|
249
|
+
*/
|
|
250
|
+
function descend(node, scope) {
|
|
251
|
+
// Skip exactly what `checkClass` owns — a widget subclass — so the two halves can never
|
|
252
|
+
// report one tree twice. NOT the wider `extends Gtk.*|Adw.*` test: that also skipped the
|
|
253
|
+
// APPLICATION bases, and an `activate` handler moved from a callback into
|
|
254
|
+
// `vfunc_activate()` of a `Gtk.Application` subclass then escaped the rule completely,
|
|
255
|
+
// which is the same bug this rule exists for wearing the most idiomatic GJS spelling.
|
|
256
|
+
// Measured before widening the entry: 0 new findings across this repository, and the
|
|
257
|
+
// false positive the application exemption was written for does not come back —
|
|
258
|
+
// `Gtk.CssProvider` is already a non-widget construction and an `Adw.AboutDialog` is
|
|
259
|
+
// presented rather than parented, so the both-signals requirement stays unsatisfied.
|
|
260
|
+
if ((node.type === 'ClassDeclaration' || node.type === 'ClassExpression') && gtkAdwSuperClass(node) !== null) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
let current = scope;
|
|
264
|
+
if (SCOPE_TYPES.has(node.type)) {
|
|
265
|
+
current = { node, constructed: [], parents: false, children: [] };
|
|
266
|
+
scope.children.push(current);
|
|
267
|
+
}
|
|
268
|
+
const type = newGtkAdwType(node);
|
|
269
|
+
if (type !== null) {
|
|
270
|
+
if (!isNonWidgetConstruction(type) && !current.constructed.includes(type))
|
|
271
|
+
current.constructed.push(type);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const method = memberCallName(node);
|
|
275
|
+
if (method !== null && PARENTING_METHODS.has(method))
|
|
276
|
+
current.parents = true;
|
|
277
|
+
}
|
|
278
|
+
for (const key of Object.keys(node)) {
|
|
279
|
+
if (key === 'parent' || key === 'tokens' || key === 'comments')
|
|
280
|
+
continue;
|
|
281
|
+
const value = node[key];
|
|
282
|
+
if (Array.isArray(value)) {
|
|
283
|
+
for (const item of value)
|
|
284
|
+
if (isNode(item))
|
|
285
|
+
descend(item, current);
|
|
286
|
+
}
|
|
287
|
+
else if (isNode(value)) {
|
|
288
|
+
descend(value, current);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** `Gtk.Box, Gtk.Label, Gtk.Button, +2 more` — the evidence, capped so a message stays readable. */
|
|
293
|
+
function listConstructed(constructed) {
|
|
294
|
+
const shown = constructed.slice(0, 3).join(', ');
|
|
295
|
+
return constructed.length > 3 ? `${shown}, +${constructed.length - 3} more` : shown;
|
|
296
|
+
}
|
|
297
|
+
/** The half of the message that is the same wherever the assembly happened. */
|
|
298
|
+
const WHY = 'a string set from TypeScript can never be marked translatable, so an interface built this ' +
|
|
299
|
+
'way cannot be translated at all. Populating data-driven children inside a template is fine ' +
|
|
300
|
+
'and is not reported.';
|
|
301
|
+
export const preferBlueprintTemplateRule = {
|
|
302
|
+
create(context) {
|
|
303
|
+
const checkClass = (node) => {
|
|
304
|
+
const base = gtkAdwSuperClass(node);
|
|
305
|
+
if (base === null)
|
|
306
|
+
return;
|
|
307
|
+
const body = node.body;
|
|
308
|
+
if (!body || body.type !== 'ClassBody')
|
|
309
|
+
return;
|
|
310
|
+
if (declaresTemplate(body))
|
|
311
|
+
return;
|
|
312
|
+
const constructed = [];
|
|
313
|
+
let parents = false;
|
|
314
|
+
walk(body, (inner) => {
|
|
315
|
+
const type = newGtkAdwType(inner);
|
|
316
|
+
if (type !== null) {
|
|
317
|
+
if (!isNonWidgetConstruction(type) && !constructed.includes(type))
|
|
318
|
+
constructed.push(type);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const method = memberCallName(inner);
|
|
322
|
+
if (method !== null && PARENTING_METHODS.has(method))
|
|
323
|
+
parents = true;
|
|
324
|
+
});
|
|
325
|
+
// BOTH signals, deliberately: constructing without parenting is a model or an
|
|
326
|
+
// adjustment, and parenting without constructing is moving an existing widget.
|
|
327
|
+
if (constructed.length === 0 || !parents)
|
|
328
|
+
return;
|
|
329
|
+
context.report({
|
|
330
|
+
message: `\`${base}\` subclass assembles its interface in TypeScript ` +
|
|
331
|
+
`(${listConstructed(constructed)}) with no Blueprint \`Template\`. Declare the widget ` +
|
|
332
|
+
`tree in a co-located \`.blp\` and keep only logic here — ${WHY}`,
|
|
333
|
+
node,
|
|
334
|
+
});
|
|
335
|
+
};
|
|
336
|
+
/**
|
|
337
|
+
* Report the DEEPEST scope that holds both signals, once.
|
|
338
|
+
*
|
|
339
|
+
* Signals bubble outward so that a function which builds a container and a callback which
|
|
340
|
+
* fills it are one finding rather than two halves that each look ordinary; reporting at the
|
|
341
|
+
* innermost scope that is already complete then puts the message on the code a reader can
|
|
342
|
+
* actually move into a `.blp`, instead of on the whole module.
|
|
343
|
+
*/
|
|
344
|
+
const settle = (scope) => {
|
|
345
|
+
const constructed = [...scope.constructed];
|
|
346
|
+
let parents = scope.parents;
|
|
347
|
+
let reported = false;
|
|
348
|
+
for (const child of scope.children) {
|
|
349
|
+
const inner = settle(child);
|
|
350
|
+
for (const type of inner.constructed)
|
|
351
|
+
if (!constructed.includes(type))
|
|
352
|
+
constructed.push(type);
|
|
353
|
+
if (inner.parents)
|
|
354
|
+
parents = true;
|
|
355
|
+
if (inner.reported)
|
|
356
|
+
reported = true;
|
|
357
|
+
}
|
|
358
|
+
if (!reported && constructed.length > 0 && parents) {
|
|
359
|
+
context.report({
|
|
360
|
+
message: `${describeScope(scope.node)} in TypeScript (${listConstructed(constructed)}) outside ` +
|
|
361
|
+
`any widget class, so no Blueprint \`Template\` can hold it. Declare the widget tree ` +
|
|
362
|
+
`in a co-located \`.blp\`, register it on a widget subclass and keep only logic here ` +
|
|
363
|
+
`— ${WHY}`,
|
|
364
|
+
node: scope.node,
|
|
365
|
+
});
|
|
366
|
+
reported = true;
|
|
367
|
+
}
|
|
368
|
+
return { constructed, parents, reported };
|
|
369
|
+
};
|
|
370
|
+
const checkModule = (program) => {
|
|
371
|
+
// The statements, not the Program node: `descend` skips `tokens`/`comments` by key, but
|
|
372
|
+
// starting at the body keeps that a belt-and-braces guard rather than the only one.
|
|
373
|
+
const root = { node: program, constructed: [], parents: false, children: [] };
|
|
374
|
+
for (const statement of program.body ?? [])
|
|
375
|
+
descend(statement, root);
|
|
376
|
+
settle(root);
|
|
377
|
+
};
|
|
378
|
+
return { ClassDeclaration: checkClass, ClassExpression: checkClass, Program: checkModule };
|
|
379
|
+
},
|
|
380
|
+
};
|