@swiftuijs/twill-linter 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Saiya and twill contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,43 @@
1
- # Temporary Holding Version
1
+ # <img src="https://twill.evecalm.com/logo.png" alt="Twill hummingbird" align="right" width="40" height="40" /> Twill linter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An ESLint 9/10 flat-config plugin for `.twill` and `.twillx`, with typescript-eslint rules, original-source diagnostics and conservative fixes.
4
+
5
+ Install in your application:
6
+
7
+ ```sh
8
+ pnpm add -D @swiftuijs/twill-linter eslint
9
+ ```
10
+
11
+ `eslint.config.mjs`:
12
+
13
+ ```js
14
+ import twill from '@swiftuijs/twill-linter';
15
+ export default [
16
+ { ignores: ['**/dist/**'] },
17
+ ...twill.configs.recommended,
18
+ { languageOptions: { globals: { console: 'readonly' } } },
19
+ ];
20
+ ```
21
+
22
+ ```sh
23
+ pnpm exec eslint src
24
+ pnpm exec eslint src --fix
25
+ ```
26
+
27
+ `recommended` supplies standard JS and typescript-eslint recommended rules. `recommendedTypeChecked` additionally supplies typed rules; Twill files need a nearby tsconfig and are checked against Twill's virtual TypeScript program. Native TS files use typescript-eslint's project service normally. Add ordinary framework/environment globals as with any ESLint project.
28
+
29
+ `recommendedTypeChecked` also enables `@typescript-eslint/switch-exhaustiveness-check`. A switch over a discriminated union must explicitly cover each variant, even when it has a `default` branch. Adding a variant then exposes missing handlers in both Twill and native TS files. This check is opt-in linting, not a new compiler guarantee; run ESLint in CI to enforce it. Ordinary `recommended` does not require type information. See [practical patterns](https://twill.evecalm.com/patterns) for an example.
30
+
31
+ The processor honors the project Twill/JSX settings. Generated-only diagnostics are suppressed. Fixes and suggestions are offered only when their ranges and replaced text map exactly to original source; fixes requiring compiler-generated syntax are withheld. `disposeProjects()` releases the cached typed projects when embedding the plugin in a long-lived process. The typed cache checks existing project/configuration file mtimes; restart linting after adding/deleting files to refresh discovered roots.
32
+
33
+ For VS Code, use Microsoft's ESLint extension and add `twill-typescript` and `twill-tsx` to `eslint.validate`. The Twill VSIX supplies language IDs and TypeScript diagnostics; ESLint supplies lint rules and fixes.
34
+
35
+ Custom rule overrides should also match the virtual TS/TSX files, as with other ESLint processors:
36
+
37
+ ```js
38
+ { files: ['**/*.{ts,tsx,twill,twillx}'], rules: { 'prefer-const': 'error' } }
39
+ ```
40
+
41
+ Framework ESLint plugins can attach their ordinary TS/TSX rules to these virtual files. Keep ESLint formatting rules disabled and let Prettier handle layout; rewrites spanning generated syntax cannot always be mapped back safely.
42
+
43
+ [Documentation](https://twill.evecalm.com/tooling) · [Issues](https://github.com/swiftuijs/twill/issues) · MIT licensed · Built by [forth.ink](https://forth.ink).
@@ -0,0 +1,8 @@
1
+ import { ESLint, Linter } from 'eslint';
2
+ declare const plugin: ESLint.Plugin & {
3
+ configs: Record<string, Linter.Config[]>;
4
+ };
5
+ export declare const configs: Record<string, Linter.Config[]>;
6
+ export declare const processors: Record<string, Linter.Processor>;
7
+ export declare function disposeProjects(): void;
8
+ export default plugin;
package/dist/index.js ADDED
@@ -0,0 +1,325 @@
1
+ import { generatedPosition, originalPosition, transform } from "@swiftuijs/twill";
2
+ import { TwillProject, virtualFilename } from "@swiftuijs/twill/project";
3
+ import * as parser from "@typescript-eslint/parser";
4
+ import typescriptPlugin from "@typescript-eslint/eslint-plugin";
5
+ import js from "@eslint/js";
6
+ import ts from "typescript";
7
+ import { loadConfig } from "@swiftuijs/twill/config";
8
+ import { dirname } from "node:path";
9
+ import { statSync } from "node:fs";
10
+ var package_default = {
11
+ name: "@swiftuijs/twill-linter",
12
+ version: "0.1.1",
13
+ type: "module",
14
+ license: "MIT",
15
+ repository: {
16
+ "type": "git",
17
+ "url": "git+https://github.com/swiftuijs/twill.git",
18
+ "directory": "packages/linter"
19
+ },
20
+ files: [
21
+ "dist",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ exports: { ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ } },
29
+ scripts: {
30
+ "build": "vite build",
31
+ "typecheck": "tsc --noEmit -p tsconfig.json",
32
+ "test:unit": "vitest run",
33
+ "test:watch": "vitest",
34
+ "test:coverage": "vitest run --coverage",
35
+ "test:package": "node tests/consumer.mjs",
36
+ "prepack": "pnpm run build",
37
+ "test": "pnpm run test:unit"
38
+ },
39
+ dependencies: {
40
+ "@swiftuijs/twill": "workspace:*",
41
+ "@typescript-eslint/parser": "^8.71.0",
42
+ "@typescript-eslint/eslint-plugin": "^8.71.0",
43
+ "@eslint/js": "catalog:",
44
+ "typescript": "catalog:"
45
+ },
46
+ devDependencies: {
47
+ "vite": "catalog:",
48
+ "vite-plugin-dts": "^5.1.2",
49
+ "@types/node": "catalog:",
50
+ "typescript": "catalog:",
51
+ "eslint": "catalog:",
52
+ "vitest": "catalog:",
53
+ "@vitest/coverage-v8": "catalog:"
54
+ },
55
+ engines: { "node": "^20.19.0 || >=22.12.0" },
56
+ publishConfig: { "access": "public" },
57
+ peerDependencies: { "eslint": "^9.0.0 || ^10.0.0" },
58
+ author: {
59
+ "name": "forth.ink",
60
+ "url": "https://forth.ink"
61
+ },
62
+ homepage: "https://twill.evecalm.com/",
63
+ bugs: { "url": "https://github.com/swiftuijs/twill/issues" },
64
+ keywords: [
65
+ "twill",
66
+ "eslint",
67
+ "eslint-plugin",
68
+ "typescript",
69
+ "linter"
70
+ ],
71
+ description: "ESLint flat configurations for Twill with TypeScript rules, original-source diagnostics and safe fixes."
72
+ };
73
+ //#endregion
74
+ //#region src/positions.ts
75
+ /** Source-map lines use LF; ESLint positions also count CR and Unicode separators. */
76
+ var SourcePositions = class {
77
+ native = [0];
78
+ mapped = [0];
79
+ constructor(text) {
80
+ const breaks = /\r\n|[\n\r\u2028\u2029]/g;
81
+ while (breaks.exec(text)) this.native.push(breaks.lastIndex);
82
+ for (let index = 0; index < text.length; index++) if (text[index] === "\n") this.mapped.push(index + 1);
83
+ }
84
+ position(starts, offset) {
85
+ let low = 0;
86
+ let high = starts.length;
87
+ while (low + 1 < high) {
88
+ const middle = low + high >>> 1;
89
+ if (starts[middle] <= offset) low = middle;
90
+ else high = middle;
91
+ }
92
+ return {
93
+ line: low,
94
+ character: offset - starts[low]
95
+ };
96
+ }
97
+ getLineAndCharacterOfPosition(offset) {
98
+ return this.position(this.native, offset);
99
+ }
100
+ getPositionOfLineAndCharacter(line, column) {
101
+ return this.native[line] + column;
102
+ }
103
+ mapPosition(offset) {
104
+ const position = this.position(this.mapped, offset);
105
+ return {
106
+ line: position.line + 1,
107
+ column: position.character
108
+ };
109
+ }
110
+ mapOffset(line, column) {
111
+ return this.mapped[line - 1] + column;
112
+ }
113
+ };
114
+ //#endregion
115
+ //#region src/index.ts
116
+ var pending = /* @__PURE__ */ new Map();
117
+ var projects = /* @__PURE__ */ new Map();
118
+ var normalize = (filename) => filename.replaceAll("\\", "/");
119
+ var stamp = (filename) => {
120
+ try {
121
+ return statSync(filename).mtimeMs;
122
+ } catch {
123
+ return -1;
124
+ }
125
+ };
126
+ function projectFor(filename) {
127
+ const config = ts.findConfigFile(dirname(filename), ts.sys.fileExists);
128
+ if (!config) throw new Error("Typed Twill linting requires a tsconfig.json.");
129
+ let cached = projects.get(config);
130
+ if (cached && cached.project.configFiles.some((file) => cached.times.get(file) !== stamp(file))) {
131
+ cached.project.dispose();
132
+ projects.delete(config);
133
+ cached = void 0;
134
+ }
135
+ if (!cached) {
136
+ cached = {
137
+ project: new TwillProject(config),
138
+ times: /* @__PURE__ */ new Map()
139
+ };
140
+ for (const file of cached.project.configFiles) cached.times.set(file, stamp(file));
141
+ if (projects.size >= 8) {
142
+ const oldest = projects.keys().next().value;
143
+ projects.get(oldest).project.dispose();
144
+ projects.delete(oldest);
145
+ }
146
+ projects.set(config, cached);
147
+ }
148
+ for (const file of cached.project.sourceFiles()) {
149
+ const time = stamp(file);
150
+ if (cached.times.has(file) && cached.times.get(file) !== time) cached.project.refresh(file);
151
+ cached.times.set(file, time);
152
+ }
153
+ return cached.project;
154
+ }
155
+ function originalOffset(state, offset) {
156
+ const position = state.generated.mapPosition(offset);
157
+ const point = originalPosition(state.result, position.line, position.column);
158
+ return point.line == null || point.column == null ? void 0 : state.original.mapOffset(point.line, point.column);
159
+ }
160
+ function fix(state, value) {
161
+ if (!value) return void 0;
162
+ const [start, end] = value.range;
163
+ const offset = originalOffset(state, start);
164
+ if (offset === void 0) return void 0;
165
+ const position = state.original.mapPosition(offset);
166
+ const generated = generatedPosition(state.result, position.line, position.column);
167
+ if (generated.line == null || generated.column == null || state.generated.mapOffset(generated.line, generated.column) !== start || state.source.slice(offset, offset + end - start) !== state.result.code.slice(start, end)) return void 0;
168
+ return {
169
+ range: [offset, offset + end - start],
170
+ text: value.text
171
+ };
172
+ }
173
+ var processor = {
174
+ supportsAutofix: true,
175
+ preprocess(source, filename) {
176
+ filename = normalize(filename);
177
+ const state = {
178
+ source,
179
+ filename,
180
+ original: new SourcePositions(source)
181
+ };
182
+ pending.set(filename, state);
183
+ try {
184
+ const config = ts.findConfigFile(dirname(filename), ts.sys.fileExists);
185
+ state.result = transform(source, {
186
+ ...loadConfig(config ? dirname(config) : dirname(filename)),
187
+ filename
188
+ });
189
+ state.generated = new SourcePositions(state.result.code);
190
+ return [{
191
+ text: state.result.code,
192
+ filename: filename.endsWith(".twillx") ? "source.tsx" : "source.ts"
193
+ }];
194
+ } catch (error) {
195
+ state.error = error;
196
+ return [];
197
+ }
198
+ },
199
+ postprocess(lists, filename) {
200
+ filename = normalize(filename);
201
+ const state = pending.get(filename);
202
+ pending.delete(filename);
203
+ state.project?.update(filename);
204
+ if (state.error) return [{
205
+ ruleId: null,
206
+ severity: 2,
207
+ fatal: true,
208
+ message: state.error.message,
209
+ line: state.error.line ?? 1,
210
+ column: (state.error.column ?? 0) + 1
211
+ }];
212
+ return lists.flat().flatMap((message) => {
213
+ if (!message.line || !message.column) return [message];
214
+ const generated = state.generated;
215
+ const start = generated.getPositionOfLineAndCharacter(message.line - 1, message.column - 1);
216
+ let offset = originalOffset(state, start);
217
+ let expressionSwitch = false;
218
+ if (message.ruleId === "@typescript-eslint/switch-exhaustiveness-check" && state.project && (offset === void 0 || state.result.code[start] !== state.source[offset])) {
219
+ const file = state.project.service.getProgram()?.getSourceFile(virtualFilename(filename));
220
+ const visit = (node) => {
221
+ if (ts.isSwitchStatement(node) && node.expression.getStart() === start) {
222
+ const sourceStart = originalOffset(state, node.getStart());
223
+ if (sourceStart !== void 0 && state.source.slice(sourceStart, sourceStart + 6) === "switch") {
224
+ offset = sourceStart;
225
+ expressionSwitch = true;
226
+ }
227
+ } else if (start >= node.getFullStart() && start < node.end) ts.forEachChild(node, visit);
228
+ };
229
+ if (file) visit(file);
230
+ }
231
+ if (offset === void 0 || !expressionSwitch && start < state.result.code.length && state.result.code[start] !== state.source[offset]) return [];
232
+ const position = state.original.getLineAndCharacterOfPosition(offset);
233
+ const endOffset = expressionSwitch ? offset + 6 : message.endLine && message.endColumn ? originalOffset(state, generated.getPositionOfLineAndCharacter(message.endLine - 1, message.endColumn - 1)) : void 0;
234
+ const end = endOffset === void 0 ? void 0 : state.original.getLineAndCharacterOfPosition(endOffset);
235
+ return [{
236
+ ...message,
237
+ line: position.line + 1,
238
+ column: position.character + 1,
239
+ endLine: end && end.line + 1,
240
+ endColumn: end && end.character + 1,
241
+ fix: fix(state, message.fix),
242
+ suggestions: message.suggestions?.flatMap((suggestion) => {
243
+ const mapped = fix(state, suggestion.fix);
244
+ return mapped ? [{
245
+ ...suggestion,
246
+ fix: mapped
247
+ }] : [];
248
+ })
249
+ }];
250
+ });
251
+ }
252
+ };
253
+ var virtualParser = {
254
+ meta: {
255
+ name: "@swiftuijs/twill-linter/parser",
256
+ version: package_default.version
257
+ },
258
+ parseForESLint(code, options) {
259
+ const original = normalize(options.filePath ?? "").replace(/\/(?:\d+_)?source\.tsx?$/, "");
260
+ const state = pending.get(original);
261
+ if (!state) return parser.parseForESLint(code, options);
262
+ if (!(options.projectService || options.project || options.programs)) return parser.parseForESLint(code, options);
263
+ const project = projectFor(original);
264
+ project.update(original, state.source);
265
+ state.project = project;
266
+ return parser.parseForESLint(code, {
267
+ ...options,
268
+ filePath: virtualFilename(original),
269
+ projectService: false,
270
+ project: void 0,
271
+ programs: [project.service.getProgram()]
272
+ });
273
+ }
274
+ };
275
+ var plugin = {
276
+ meta: {
277
+ name: "@swiftuijs/twill-linter",
278
+ version: package_default.version
279
+ },
280
+ processors: { twill: processor },
281
+ configs: {}
282
+ };
283
+ var files = [
284
+ "**/*.ts",
285
+ "**/*.tsx",
286
+ "**/*.mts",
287
+ "**/*.cts"
288
+ ];
289
+ function configuration(typed) {
290
+ const recommended = typescriptPlugin.configs[typed ? "flat/recommended-type-checked" : "flat/recommended"];
291
+ return [
292
+ js.configs.recommended,
293
+ {
294
+ files: ["**/*.twill", "**/*.twillx"],
295
+ plugins: { twill: plugin },
296
+ processor: "twill/twill"
297
+ },
298
+ ...recommended.map((config) => ({
299
+ ...config,
300
+ files,
301
+ languageOptions: {
302
+ ...config.languageOptions,
303
+ parser: virtualParser,
304
+ ...typed ? { parserOptions: { projectService: true } } : {}
305
+ }
306
+ })),
307
+ ...typed ? [{
308
+ files,
309
+ rules: { "@typescript-eslint/switch-exhaustiveness-check": ["error", { considerDefaultExhaustiveForUnions: false }] }
310
+ }] : []
311
+ ];
312
+ }
313
+ plugin.configs.recommended = configuration(false);
314
+ plugin.configs.recommendedTypeChecked = configuration(true);
315
+ var configs = plugin.configs;
316
+ var processors = { twill: processor };
317
+ function disposeProjects() {
318
+ for (const { project } of projects.values()) project.dispose();
319
+ projects.clear();
320
+ pending.clear();
321
+ }
322
+ //#endregion
323
+ export { configs, plugin as default, disposeProjects, processors };
324
+
325
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["files","metadata"],"sources":["../package.json","../src/positions.ts","../src/index.ts"],"sourcesContent":["{\n \"name\": \"@swiftuijs/twill-linter\",\n \"version\": \"0.1.1\",\n \"type\": \"module\",\n \"license\": \"MIT\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+https://github.com/swiftuijs/twill.git\",\n \"directory\": \"packages/linter\"\n },\n \"files\": [\n \"dist\",\n \"README.md\",\n \"LICENSE\"\n ],\n \"exports\": {\n \".\": {\n \"types\": \"./dist/index.d.ts\",\n \"import\": \"./dist/index.js\"\n }\n },\n \"scripts\": {\n \"build\": \"vite build\",\n \"typecheck\": \"tsc --noEmit -p tsconfig.json\",\n \"test:unit\": \"vitest run\",\n \"test:watch\": \"vitest\",\n \"test:coverage\": \"vitest run --coverage\",\n \"test:package\": \"node tests/consumer.mjs\",\n \"prepack\": \"pnpm run build\",\n \"test\": \"pnpm run test:unit\"\n },\n \"dependencies\": {\n \"@swiftuijs/twill\": \"workspace:*\",\n \"@typescript-eslint/parser\": \"^8.71.0\",\n \"@typescript-eslint/eslint-plugin\": \"^8.71.0\",\n \"@eslint/js\": \"catalog:\",\n \"typescript\": \"catalog:\"\n },\n \"devDependencies\": {\n \"vite\": \"catalog:\",\n \"vite-plugin-dts\": \"^5.1.2\",\n \"@types/node\": \"catalog:\",\n \"typescript\": \"catalog:\",\n \"eslint\": \"catalog:\",\n \"vitest\": \"catalog:\",\n \"@vitest/coverage-v8\": \"catalog:\"\n },\n \"engines\": {\n \"node\": \"^20.19.0 || >=22.12.0\"\n },\n \"publishConfig\": {\n \"access\": \"public\"\n },\n \"peerDependencies\": {\n \"eslint\": \"^9.0.0 || ^10.0.0\"\n },\n \"author\": {\n \"name\": \"forth.ink\",\n \"url\": \"https://forth.ink\"\n },\n \"homepage\": \"https://twill.evecalm.com/\",\n \"bugs\": {\n \"url\": \"https://github.com/swiftuijs/twill/issues\"\n },\n \"keywords\": [\n \"twill\",\n \"eslint\",\n \"eslint-plugin\",\n \"typescript\",\n \"linter\"\n ],\n \"description\": \"ESLint flat configurations for Twill with TypeScript rules, original-source diagnostics and safe fixes.\"\n}\n","/** Source-map lines use LF; ESLint positions also count CR and Unicode separators. */\nexport class SourcePositions {\n private native = [0];\n private mapped = [0];\n\n constructor(text: string) {\n const breaks = /\\r\\n|[\\n\\r\\u2028\\u2029]/g;\n while (breaks.exec(text)) this.native.push(breaks.lastIndex);\n for (let index = 0; index < text.length; index++)\n if (text[index] === '\\n') this.mapped.push(index + 1);\n }\n\n private position(starts: number[], offset: number) {\n let low = 0;\n let high = starts.length;\n while (low + 1 < high) {\n const middle = (low + high) >>> 1;\n if (starts[middle]! <= offset) low = middle;\n else high = middle;\n }\n return { line: low, character: offset - starts[low]! };\n }\n\n getLineAndCharacterOfPosition(offset: number) {\n return this.position(this.native, offset);\n }\n\n getPositionOfLineAndCharacter(line: number, column: number) {\n return this.native[line]! + column;\n }\n\n mapPosition(offset: number) {\n const position = this.position(this.mapped, offset);\n return { line: position.line + 1, column: position.character };\n }\n\n mapOffset(line: number, column: number) {\n return this.mapped[line - 1]! + column;\n }\n}\n","import {\n transform,\n originalPosition,\n generatedPosition,\n type TransformResult,\n} from '@swiftuijs/twill';\nimport { TwillProject, virtualFilename, sourceFilename } from '@swiftuijs/twill/project';\nimport * as parser from '@typescript-eslint/parser';\nimport typescriptPlugin from '@typescript-eslint/eslint-plugin';\nimport js from '@eslint/js';\nimport ts from 'typescript';\nimport { loadConfig } from '@swiftuijs/twill/config';\nimport { dirname, resolve } from 'node:path';\nimport { statSync } from 'node:fs';\nimport type { ESLint, Linter, Rule } from 'eslint';\nimport metadata from '../package.json' with { type: 'json' };\nimport { SourcePositions } from './positions.js';\n\ntype State = {\n source: string;\n filename: string;\n result?: TransformResult;\n error?: any;\n original: SourcePositions;\n generated?: SourcePositions;\n project?: TwillProject;\n};\nconst pending = new Map<string, State>();\nconst projects = new Map<string, { project: TwillProject; times: Map<string, number> }>();\nconst normalize = (filename: string) => filename.replaceAll('\\\\', '/');\nconst stamp = (filename: string) => {\n try {\n return statSync(filename).mtimeMs;\n } catch {\n return -1;\n }\n};\n\nfunction projectFor(filename: string) {\n const config = ts.findConfigFile(dirname(filename), ts.sys.fileExists);\n if (!config) throw new Error('Typed Twill linting requires a tsconfig.json.');\n let cached = projects.get(config);\n if (\n cached &&\n cached.project.configFiles.some((file) => cached!.times.get(file) !== stamp(file))\n ) {\n cached.project.dispose();\n projects.delete(config);\n cached = undefined;\n }\n if (!cached) {\n cached = { project: new TwillProject(config), times: new Map() };\n for (const file of cached.project.configFiles) cached.times.set(file, stamp(file));\n if (projects.size >= 8) {\n const oldest = projects.keys().next().value!;\n projects.get(oldest)!.project.dispose();\n projects.delete(oldest);\n }\n projects.set(config, cached);\n }\n for (const file of cached.project.sourceFiles()) {\n const time = stamp(file);\n if (cached.times.has(file) && cached.times.get(file) !== time) cached.project.refresh(file);\n cached.times.set(file, time);\n }\n return cached.project;\n}\n\nfunction originalOffset(state: State, offset: number) {\n const position = state.generated!.mapPosition(offset);\n const point = originalPosition(state.result!, position.line, position.column);\n return point.line == null || point.column == null\n ? undefined\n : state.original.mapOffset(point.line, point.column);\n}\nfunction fix(state: State, value: Rule.Fix | undefined): Rule.Fix | undefined {\n if (!value) return undefined;\n const [start, end] = value.range;\n const offset = originalOffset(state, start);\n if (offset === undefined) return undefined;\n const position = state.original.mapPosition(offset);\n const generated = generatedPosition(state.result!, position.line, position.column);\n if (\n generated.line == null ||\n generated.column == null ||\n state.generated!.mapOffset(generated.line, generated.column) !== start ||\n state.source.slice(offset, offset + end - start) !== state.result!.code.slice(start, end)\n )\n return undefined;\n return { range: [offset, offset + end - start], text: value.text };\n}\n\nconst processor: Linter.Processor = {\n supportsAutofix: true,\n preprocess(source, filename) {\n filename = normalize(filename);\n const state: State = {\n source,\n filename,\n original: new SourcePositions(source),\n };\n pending.set(filename, state);\n try {\n // A project supplies the same inherited JSX settings as builds/checking.\n const config = ts.findConfigFile(dirname(filename), ts.sys.fileExists);\n state.result = transform(source, {\n ...loadConfig(config ? dirname(config) : dirname(filename)),\n filename,\n });\n state.generated = new SourcePositions(state.result.code);\n return [\n {\n text: state.result.code,\n filename: filename.endsWith('.twillx') ? 'source.tsx' : 'source.ts',\n },\n ];\n } catch (error) {\n state.error = error;\n return [];\n }\n },\n postprocess(lists, filename) {\n filename = normalize(filename);\n const state = pending.get(filename)!;\n pending.delete(filename);\n state.project?.update(filename);\n if (state.error)\n return [\n {\n ruleId: null,\n severity: 2,\n fatal: true,\n message: state.error.message,\n line: state.error.line ?? 1,\n column: (state.error.column ?? 0) + 1,\n },\n ];\n return lists.flat().flatMap((message) => {\n if (!message.line || !message.column) return [message];\n const generated = state.generated!;\n const start = generated.getPositionOfLineAndCharacter(message.line - 1, message.column - 1);\n let offset = originalOffset(state, start);\n let expressionSwitch = false;\n if (\n message.ruleId === '@typescript-eslint/switch-exhaustiveness-check' &&\n state.project &&\n (offset === undefined || state.result!.code[start] !== state.source[offset])\n ) {\n // Expression subjects use a generated once-evaluated local. Report the\n // missing union arm on the source-backed switch keyword instead.\n const file = state.project.service.getProgram()?.getSourceFile(virtualFilename(filename));\n const visit = (node: ts.Node) => {\n if (ts.isSwitchStatement(node) && node.expression.getStart() === start) {\n const sourceStart = originalOffset(state, node.getStart());\n if (\n sourceStart !== undefined &&\n state.source.slice(sourceStart, sourceStart + 6) === 'switch'\n ) {\n offset = sourceStart;\n expressionSwitch = true;\n }\n } else if (start >= node.getFullStart() && start < node.end) ts.forEachChild(node, visit);\n };\n if (file) visit(file);\n }\n if (\n offset === undefined ||\n (!expressionSwitch &&\n start < state.result!.code.length &&\n state.result!.code[start] !== state.source[offset])\n )\n return [];\n const position = state.original.getLineAndCharacterOfPosition(offset);\n const endOffset = expressionSwitch\n ? offset + 6\n : message.endLine && message.endColumn\n ? originalOffset(\n state,\n generated.getPositionOfLineAndCharacter(message.endLine - 1, message.endColumn - 1),\n )\n : undefined;\n const end =\n endOffset === undefined\n ? undefined\n : state.original.getLineAndCharacterOfPosition(endOffset);\n return [\n {\n ...message,\n line: position.line + 1,\n column: position.character + 1,\n endLine: end && end.line + 1,\n endColumn: end && end.character + 1,\n fix: fix(state, message.fix),\n suggestions: message.suggestions?.flatMap((suggestion) => {\n const mapped = fix(state, suggestion.fix);\n return mapped ? [{ ...suggestion, fix: mapped }] : [];\n }),\n },\n ];\n });\n },\n};\n\nconst virtualParser = {\n meta: { name: '@swiftuijs/twill-linter/parser', version: metadata.version },\n parseForESLint(code: string, options: any) {\n const original = normalize(options.filePath ?? '').replace(/\\/(?:\\d+_)?source\\.tsx?$/, '');\n const state = pending.get(original);\n if (!state) return parser.parseForESLint(code, options);\n const typed = options.projectService || options.project || options.programs;\n if (!typed) return parser.parseForESLint(code, options);\n const project = projectFor(original);\n project.update(original, state.source);\n state.project = project;\n return parser.parseForESLint(code, {\n ...options,\n filePath: virtualFilename(original),\n projectService: false,\n project: undefined,\n programs: [project.service.getProgram()!],\n });\n },\n};\n\nconst plugin: ESLint.Plugin & { configs: Record<string, Linter.Config[]> } = {\n meta: { name: '@swiftuijs/twill-linter', version: metadata.version },\n processors: { twill: processor },\n configs: {} as Record<string, Linter.Config[]>,\n};\nconst files = ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'];\nfunction configuration(typed: boolean): Linter.Config[] {\n const recommended = typescriptPlugin.configs[\n typed ? 'flat/recommended-type-checked' : 'flat/recommended'\n ] as Linter.Config[];\n return [\n js.configs.recommended,\n { files: ['**/*.twill', '**/*.twillx'], plugins: { twill: plugin }, processor: 'twill/twill' },\n ...recommended.map((config) => ({\n ...config,\n files,\n languageOptions: {\n ...config.languageOptions,\n parser: virtualParser,\n ...(typed ? { parserOptions: { projectService: true } } : {}),\n },\n })),\n ...(typed\n ? [\n {\n files,\n rules: {\n '@typescript-eslint/switch-exhaustiveness-check': [\n 'error',\n { considerDefaultExhaustiveForUnions: false },\n ] satisfies Linter.RuleEntry,\n },\n },\n ]\n : []),\n ];\n}\nplugin.configs.recommended = configuration(false);\nplugin.configs.recommendedTypeChecked = configuration(true);\nexport const configs: Record<string, Linter.Config[]> = plugin.configs;\nexport const processors: Record<string, Linter.Processor> = { twill: processor };\nexport function disposeProjects() {\n for (const { project } of projects.values()) project.dispose();\n projects.clear();\n pending.clear();\n}\nexport default plugin;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACCA,IAAa,kBAAb,MAA6B;CAC3B,SAAiB,CAAC,CAAC;CACnB,SAAiB,CAAC,CAAC;CAEnB,YAAY,MAAc;EACxB,MAAM,SAAS;EACf,OAAO,OAAO,KAAK,IAAI,GAAG,KAAK,OAAO,KAAK,OAAO,SAAS;EAC3D,KAAK,IAAI,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SACvC,IAAI,KAAK,WAAW,MAAM,KAAK,OAAO,KAAK,QAAQ,CAAC;CACxD;CAEA,SAAiB,QAAkB,QAAgB;EACjD,IAAI,MAAM;EACV,IAAI,OAAO,OAAO;EAClB,OAAO,MAAM,IAAI,MAAM;GACrB,MAAM,SAAU,MAAM,SAAU;GAChC,IAAI,OAAO,WAAY,QAAQ,MAAM;QAChC,OAAO;EACd;EACA,OAAO;GAAE,MAAM;GAAK,WAAW,SAAS,OAAO;EAAM;CACvD;CAEA,8BAA8B,QAAgB;EAC5C,OAAO,KAAK,SAAS,KAAK,QAAQ,MAAM;CAC1C;CAEA,8BAA8B,MAAc,QAAgB;EAC1D,OAAO,KAAK,OAAO,QAAS;CAC9B;CAEA,YAAY,QAAgB;EAC1B,MAAM,WAAW,KAAK,SAAS,KAAK,QAAQ,MAAM;EAClD,OAAO;GAAE,MAAM,SAAS,OAAO;GAAG,QAAQ,SAAS;EAAU;CAC/D;CAEA,UAAU,MAAc,QAAgB;EACtC,OAAO,KAAK,OAAO,OAAO,KAAM;CAClC;AACF;;;ACZA,IAAM,0BAAU,IAAI,IAAmB;AACvC,IAAM,2BAAW,IAAI,IAAmE;AACxF,IAAM,aAAa,aAAqB,SAAS,WAAW,MAAM,GAAG;AACrE,IAAM,SAAS,aAAqB;CAClC,IAAI;EACF,OAAO,SAAS,QAAQ,CAAC,CAAC;CAC5B,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAS,WAAW,UAAkB;CACpC,MAAM,SAAS,GAAG,eAAe,QAAQ,QAAQ,GAAG,GAAG,IAAI,UAAU;CACrE,IAAI,CAAC,QAAQ,MAAM,IAAI,MAAM,+CAA+C;CAC5E,IAAI,SAAS,SAAS,IAAI,MAAM;CAChC,IACE,UACA,OAAO,QAAQ,YAAY,MAAM,SAAS,OAAQ,MAAM,IAAI,IAAI,MAAM,MAAM,IAAI,CAAC,GACjF;EACA,OAAO,QAAQ,QAAQ;EACvB,SAAS,OAAO,MAAM;EACtB,SAAS,KAAA;CACX;CACA,IAAI,CAAC,QAAQ;EACX,SAAS;GAAE,SAAS,IAAI,aAAa,MAAM;GAAG,uBAAO,IAAI,IAAI;EAAE;EAC/D,KAAK,MAAM,QAAQ,OAAO,QAAQ,aAAa,OAAO,MAAM,IAAI,MAAM,MAAM,IAAI,CAAC;EACjF,IAAI,SAAS,QAAQ,GAAG;GACtB,MAAM,SAAS,SAAS,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC;GACtC,SAAS,IAAI,MAAM,CAAC,CAAE,QAAQ,QAAQ;GACtC,SAAS,OAAO,MAAM;EACxB;EACA,SAAS,IAAI,QAAQ,MAAM;CAC7B;CACA,KAAK,MAAM,QAAQ,OAAO,QAAQ,YAAY,GAAG;EAC/C,MAAM,OAAO,MAAM,IAAI;EACvB,IAAI,OAAO,MAAM,IAAI,IAAI,KAAK,OAAO,MAAM,IAAI,IAAI,MAAM,MAAM,OAAO,QAAQ,QAAQ,IAAI;EAC1F,OAAO,MAAM,IAAI,MAAM,IAAI;CAC7B;CACA,OAAO,OAAO;AAChB;AAEA,SAAS,eAAe,OAAc,QAAgB;CACpD,MAAM,WAAW,MAAM,UAAW,YAAY,MAAM;CACpD,MAAM,QAAQ,iBAAiB,MAAM,QAAS,SAAS,MAAM,SAAS,MAAM;CAC5E,OAAO,MAAM,QAAQ,QAAQ,MAAM,UAAU,OACzC,KAAA,IACA,MAAM,SAAS,UAAU,MAAM,MAAM,MAAM,MAAM;AACvD;AACA,SAAS,IAAI,OAAc,OAAmD;CAC5E,IAAI,CAAC,OAAO,OAAO,KAAA;CACnB,MAAM,CAAC,OAAO,OAAO,MAAM;CAC3B,MAAM,SAAS,eAAe,OAAO,KAAK;CAC1C,IAAI,WAAW,KAAA,GAAW,OAAO,KAAA;CACjC,MAAM,WAAW,MAAM,SAAS,YAAY,MAAM;CAClD,MAAM,YAAY,kBAAkB,MAAM,QAAS,SAAS,MAAM,SAAS,MAAM;CACjF,IACE,UAAU,QAAQ,QAClB,UAAU,UAAU,QACpB,MAAM,UAAW,UAAU,UAAU,MAAM,UAAU,MAAM,MAAM,SACjE,MAAM,OAAO,MAAM,QAAQ,SAAS,MAAM,KAAK,MAAM,MAAM,OAAQ,KAAK,MAAM,OAAO,GAAG,GAExF,OAAO,KAAA;CACT,OAAO;EAAE,OAAO,CAAC,QAAQ,SAAS,MAAM,KAAK;EAAG,MAAM,MAAM;CAAK;AACnE;AAEA,IAAM,YAA8B;CAClC,iBAAiB;CACjB,WAAW,QAAQ,UAAU;EAC3B,WAAW,UAAU,QAAQ;EAC7B,MAAM,QAAe;GACnB;GACA;GACA,UAAU,IAAI,gBAAgB,MAAM;EACtC;EACA,QAAQ,IAAI,UAAU,KAAK;EAC3B,IAAI;GAEF,MAAM,SAAS,GAAG,eAAe,QAAQ,QAAQ,GAAG,GAAG,IAAI,UAAU;GACrE,MAAM,SAAS,UAAU,QAAQ;IAC/B,GAAG,WAAW,SAAS,QAAQ,MAAM,IAAI,QAAQ,QAAQ,CAAC;IAC1D;GACF,CAAC;GACD,MAAM,YAAY,IAAI,gBAAgB,MAAM,OAAO,IAAI;GACvD,OAAO,CACL;IACE,MAAM,MAAM,OAAO;IACnB,UAAU,SAAS,SAAS,SAAS,IAAI,eAAe;GAC1D,CACF;EACF,SAAS,OAAO;GACd,MAAM,QAAQ;GACd,OAAO,CAAC;EACV;CACF;CACA,YAAY,OAAO,UAAU;EAC3B,WAAW,UAAU,QAAQ;EAC7B,MAAM,QAAQ,QAAQ,IAAI,QAAQ;EAClC,QAAQ,OAAO,QAAQ;EACvB,MAAM,SAAS,OAAO,QAAQ;EAC9B,IAAI,MAAM,OACR,OAAO,CACL;GACE,QAAQ;GACR,UAAU;GACV,OAAO;GACP,SAAS,MAAM,MAAM;GACrB,MAAM,MAAM,MAAM,QAAQ;GAC1B,SAAS,MAAM,MAAM,UAAU,KAAK;EACtC,CACF;EACF,OAAO,MAAM,KAAK,CAAC,CAAC,SAAS,YAAY;GACvC,IAAI,CAAC,QAAQ,QAAQ,CAAC,QAAQ,QAAQ,OAAO,CAAC,OAAO;GACrD,MAAM,YAAY,MAAM;GACxB,MAAM,QAAQ,UAAU,8BAA8B,QAAQ,OAAO,GAAG,QAAQ,SAAS,CAAC;GAC1F,IAAI,SAAS,eAAe,OAAO,KAAK;GACxC,IAAI,mBAAmB;GACvB,IACE,QAAQ,WAAW,oDACnB,MAAM,YACL,WAAW,KAAA,KAAa,MAAM,OAAQ,KAAK,WAAW,MAAM,OAAO,UACpE;IAGA,MAAM,OAAO,MAAM,QAAQ,QAAQ,WAAW,CAAC,EAAE,cAAc,gBAAgB,QAAQ,CAAC;IACxF,MAAM,SAAS,SAAkB;KAC/B,IAAI,GAAG,kBAAkB,IAAI,KAAK,KAAK,WAAW,SAAS,MAAM,OAAO;MACtE,MAAM,cAAc,eAAe,OAAO,KAAK,SAAS,CAAC;MACzD,IACE,gBAAgB,KAAA,KAChB,MAAM,OAAO,MAAM,aAAa,cAAc,CAAC,MAAM,UACrD;OACA,SAAS;OACT,mBAAmB;MACrB;KACF,OAAO,IAAI,SAAS,KAAK,aAAa,KAAK,QAAQ,KAAK,KAAK,GAAG,aAAa,MAAM,KAAK;IAC1F;IACA,IAAI,MAAM,MAAM,IAAI;GACtB;GACA,IACE,WAAW,KAAA,KACV,CAAC,oBACA,QAAQ,MAAM,OAAQ,KAAK,UAC3B,MAAM,OAAQ,KAAK,WAAW,MAAM,OAAO,SAE7C,OAAO,CAAC;GACV,MAAM,WAAW,MAAM,SAAS,8BAA8B,MAAM;GACpE,MAAM,YAAY,mBACd,SAAS,IACT,QAAQ,WAAW,QAAQ,YACzB,eACE,OACA,UAAU,8BAA8B,QAAQ,UAAU,GAAG,QAAQ,YAAY,CAAC,CACpF,IACA,KAAA;GACN,MAAM,MACJ,cAAc,KAAA,IACV,KAAA,IACA,MAAM,SAAS,8BAA8B,SAAS;GAC5D,OAAO,CACL;IACE,GAAG;IACH,MAAM,SAAS,OAAO;IACtB,QAAQ,SAAS,YAAY;IAC7B,SAAS,OAAO,IAAI,OAAO;IAC3B,WAAW,OAAO,IAAI,YAAY;IAClC,KAAK,IAAI,OAAO,QAAQ,GAAG;IAC3B,aAAa,QAAQ,aAAa,SAAS,eAAe;KACxD,MAAM,SAAS,IAAI,OAAO,WAAW,GAAG;KACxC,OAAO,SAAS,CAAC;MAAE,GAAG;MAAY,KAAK;KAAO,CAAC,IAAI,CAAC;IACtD,CAAC;GACH,CACF;EACF,CAAC;CACH;AACF;AAEA,IAAM,gBAAgB;CACpB,MAAM;EAAE,MAAM;EAAkC,SAASC,gBAAS;CAAQ;CAC1E,eAAe,MAAc,SAAc;EACzC,MAAM,WAAW,UAAU,QAAQ,YAAY,EAAE,CAAC,CAAC,QAAQ,4BAA4B,EAAE;EACzF,MAAM,QAAQ,QAAQ,IAAI,QAAQ;EAClC,IAAI,CAAC,OAAO,OAAO,OAAO,eAAe,MAAM,OAAO;EAEtD,IAAI,EADU,QAAQ,kBAAkB,QAAQ,WAAW,QAAQ,WACvD,OAAO,OAAO,eAAe,MAAM,OAAO;EACtD,MAAM,UAAU,WAAW,QAAQ;EACnC,QAAQ,OAAO,UAAU,MAAM,MAAM;EACrC,MAAM,UAAU;EAChB,OAAO,OAAO,eAAe,MAAM;GACjC,GAAG;GACH,UAAU,gBAAgB,QAAQ;GAClC,gBAAgB;GAChB,SAAS,KAAA;GACT,UAAU,CAAC,QAAQ,QAAQ,WAAW,CAAE;EAC1C,CAAC;CACH;AACF;AAEA,IAAM,SAAuE;CAC3E,MAAM;EAAE,MAAM;EAA2B,SAASA,gBAAS;CAAQ;CACnE,YAAY,EAAE,OAAO,UAAU;CAC/B,SAAS,CAAC;AACZ;AACA,IAAM,QAAQ;CAAC;CAAW;CAAY;CAAY;AAAU;AAC5D,SAAS,cAAc,OAAiC;CACtD,MAAM,cAAc,iBAAiB,QACnC,QAAQ,kCAAkC;CAE5C,OAAO;EACL,GAAG,QAAQ;EACX;GAAE,OAAO,CAAC,cAAc,aAAa;GAAG,SAAS,EAAE,OAAO,OAAO;GAAG,WAAW;EAAc;EAC7F,GAAG,YAAY,KAAK,YAAY;GAC9B,GAAG;GACH;GACA,iBAAiB;IACf,GAAG,OAAO;IACV,QAAQ;IACR,GAAI,QAAQ,EAAE,eAAe,EAAE,gBAAgB,KAAK,EAAE,IAAI,CAAC;GAC7D;EACF,EAAE;EACF,GAAI,QACA,CACE;GACE;GACA,OAAO,EACL,kDAAkD,CAChD,SACA,EAAE,oCAAoC,MAAM,CAC9C,EACF;EACF,CACF,IACA,CAAC;CACP;AACF;AACA,OAAO,QAAQ,cAAc,cAAc,KAAK;AAChD,OAAO,QAAQ,yBAAyB,cAAc,IAAI;AAC1D,IAAa,UAA2C,OAAO;AAC/D,IAAa,aAA+C,EAAE,OAAO,UAAU;AAC/E,SAAgB,kBAAkB;CAChC,KAAK,MAAM,EAAE,aAAa,SAAS,OAAO,GAAG,QAAQ,QAAQ;CAC7D,SAAS,MAAM;CACf,QAAQ,MAAM;AAChB"}
@@ -0,0 +1,17 @@
1
+ /** Source-map lines use LF; ESLint positions also count CR and Unicode separators. */
2
+ export declare class SourcePositions {
3
+ private native;
4
+ private mapped;
5
+ constructor(text: string);
6
+ private position;
7
+ getLineAndCharacterOfPosition(offset: number): {
8
+ line: number;
9
+ character: number;
10
+ };
11
+ getPositionOfLineAndCharacter(line: number, column: number): number;
12
+ mapPosition(offset: number): {
13
+ line: number;
14
+ column: number;
15
+ };
16
+ mapOffset(line: number, column: number): number;
17
+ }
package/package.json CHANGED
@@ -1,6 +1,72 @@
1
1
  {
2
2
  "name": "@swiftuijs/twill-linter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/swiftuijs/twill.git",
9
+ "directory": "packages/linter"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js"
20
+ }
21
+ },
22
+ "dependencies": {
23
+ "@typescript-eslint/parser": "^8.71.0",
24
+ "@typescript-eslint/eslint-plugin": "^8.71.0",
25
+ "@eslint/js": "^9.39.5",
26
+ "typescript": "5.9.3",
27
+ "@swiftuijs/twill": "0.1.1"
28
+ },
29
+ "devDependencies": {
30
+ "vite": "^8.3.2",
31
+ "vite-plugin-dts": "^5.1.2",
32
+ "@types/node": "^26.6.4",
33
+ "typescript": "5.9.3",
34
+ "eslint": "^10.12.0",
35
+ "vitest": "^5.0.3",
36
+ "@vitest/coverage-v8": "^5.0.3"
37
+ },
38
+ "engines": {
39
+ "node": "^20.19.0 || >=22.12.0"
40
+ },
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "peerDependencies": {
45
+ "eslint": "^9.0.0 || ^10.0.0"
46
+ },
47
+ "author": {
48
+ "name": "forth.ink",
49
+ "url": "https://forth.ink"
50
+ },
51
+ "homepage": "https://twill.evecalm.com/",
52
+ "bugs": {
53
+ "url": "https://github.com/swiftuijs/twill/issues"
54
+ },
55
+ "keywords": [
56
+ "twill",
57
+ "eslint",
58
+ "eslint-plugin",
59
+ "typescript",
60
+ "linter"
61
+ ],
62
+ "description": "ESLint flat configurations for Twill with TypeScript rules, original-source diagnostics and safe fixes.",
63
+ "scripts": {
64
+ "build": "vite build",
65
+ "typecheck": "tsc --noEmit -p tsconfig.json",
66
+ "test:unit": "vitest run",
67
+ "test:watch": "vitest",
68
+ "test:coverage": "vitest run --coverage",
69
+ "test:package": "node tests/consumer.mjs",
70
+ "test": "pnpm run test:unit"
71
+ }
6
72
  }