larrylint 0.0.1 β 0.1.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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/_chunks/check.mjs +76 -0
- package/dist/_chunks/check2.mjs +50 -0
- package/dist/_chunks/init.mjs +149 -0
- package/dist/_chunks/package.mjs +4 -0
- package/dist/_chunks/preset.mjs +586 -0
- package/dist/cli/index.d.mts +1 -0
- package/dist/cli/index.mjs +23 -0
- package/dist/index.d.mts +59 -0
- package/dist/index.mjs +2 -0
- package/package.json +56 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present Frederik BuΓmann
|
|
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
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# π¦ larrylint
|
|
2
|
+
|
|
3
|
+
[![Github Actions][github-actions-src]][github-actions-href]
|
|
4
|
+
[![NPM version][npm-version-src]][npm-version-href]
|
|
5
|
+
[![NPM last update][npm-last-update-src]][npm-last-update-href]
|
|
6
|
+
[![License][license-src]][license-href]
|
|
7
|
+
|
|
8
|
+
Opinionated structure rules for [Laioutr](https://laioutr.com) apps: an ESLint preset for your editor and a CLI for CI.
|
|
9
|
+
|
|
10
|
+
Laioutr apps are Nuxt modules with a lot of moving parts: sections and blocks, orchestr handlers, middleware, API clients, server routes. larrylint keeps them where they belong and stops the imports that make a codebase drift, like app code pulling in server code, handlers importing each other, or one business domain reaching into another.
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
Check the Laioutr app in the current folder. No install and no config needed:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
npx larrylint
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Set it up for good. This installs larrylint, adds its rules to your `eslint.config`, and records existing violations in a baseline so only new code has to follow the rules:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
npx larrylint init
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
After that, `eslint .` and your editor report larrylint's rules next to your own.
|
|
27
|
+
|
|
28
|
+
## Rules
|
|
29
|
+
|
|
30
|
+
| Rule | What it checks |
|
|
31
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| `larrylint/layers` | App code doesn't import server code and vice versa; shared code imports neither. Runtime code doesn't import build-time code. Nothing imports orchestr handlers. Handlers and server utils don't import from `server/client/`, and only handlers and media libraries import middleware. Utils are the bottom layer. Domains stay apart. |
|
|
33
|
+
| `larrylint/orchestr-files` | Laioutr loads every file in `orchestr/` as a server plugin, so only handler files belong there, in a domain folder, exporting nothing but their handler. |
|
|
34
|
+
| `larrylint/definitions` | `defineSection()` lives in `app/sections/Section*.vue`, `defineBlock()` in `app/blocks/Block*.vue`, with a `component` name that matches the file and no top-level schema fields named `style`, `class`, `key`, `ref`, `is`, `slot`, `refFor` or `refKey`, which Vue swallows before they reach the component. |
|
|
35
|
+
| `larrylint/button-type` | No `type` on the ui-kit button: it always renders its `button-type` prop, so `type="submit"` silently renders a dead button. Autofixable. |
|
|
36
|
+
|
|
37
|
+
Type imports are fine across most layers, since they don't end up in the bundle.
|
|
38
|
+
|
|
39
|
+
## Layout
|
|
40
|
+
|
|
41
|
+
larrylint expects the layout of Laioutr's [app starter](https://github.com/laioutr/app-starter):
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
src/
|
|
45
|
+
βββ module.ts # build time
|
|
46
|
+
βββ runtime/
|
|
47
|
+
βββ app/
|
|
48
|
+
β βββ sections/ # Section*.vue with defineSection()
|
|
49
|
+
β βββ blocks/ # Block*.vue with defineBlock()
|
|
50
|
+
β βββ components/
|
|
51
|
+
β βββ composables/
|
|
52
|
+
β βββ overrides/ # replacements for upstream components
|
|
53
|
+
β βββ plugins/
|
|
54
|
+
β βββ utils/
|
|
55
|
+
βββ server/
|
|
56
|
+
β βββ orchestr/
|
|
57
|
+
β β βββ <domain>/ # *.query.ts, *.resolver.ts, *.link.ts, *.action.ts, *.template.ts, *.page-index.ts
|
|
58
|
+
β β βββ plugins/
|
|
59
|
+
β βββ middleware/ # orchestr middleware and the builders handlers use
|
|
60
|
+
β βββ client/ # API clients, nothing else
|
|
61
|
+
β βββ api/ # server routes
|
|
62
|
+
β βββ plugins/ # Nitro plugins
|
|
63
|
+
β βββ media-library/
|
|
64
|
+
β βββ utils/
|
|
65
|
+
β βββ <domain>/
|
|
66
|
+
βββ shared/ # code for both the app and the server
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A domain is a folder in `server/orchestr/`, together with the `server/utils/` folder of the same name. Other folders in `server/utils/`, like `tracking/`, hold shared helpers that every domain may use.
|
|
70
|
+
|
|
71
|
+
## Baseline
|
|
72
|
+
|
|
73
|
+
On an existing codebase, `larrylint init` records all current violations in `larrylint-baseline.json`, counted per file and rule. A file stays quiet as long as it has no more violations of a rule than recorded. Once it gets more, all of them show again, like ESLint's bulk suppressions. The baseline applies in your editor, in `eslint .` and in the CLI.
|
|
74
|
+
|
|
75
|
+
After fixing old violations, shrink the baseline:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
npx larrylint --baseline
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Configuration
|
|
82
|
+
|
|
83
|
+
Most apps need none. To let every domain import a domain, add it to `sharedDomains` in your `package.json`:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"larrylint": {
|
|
88
|
+
"sharedDomains": ["product"]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or in a `larrylint.config.ts`:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { defineLarrylintConfig } from 'larrylint'
|
|
97
|
+
|
|
98
|
+
export default defineLarrylintConfig({
|
|
99
|
+
sharedDomains: ['product'],
|
|
100
|
+
})
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## ESLint
|
|
104
|
+
|
|
105
|
+
`larrylint init` adds the rules to your `eslint.config` for you. To do it by hand with `@nuxt/eslint-config` or `@antfu/eslint-config`:
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
import { createConfigForNuxt } from '@nuxt/eslint-config/flat'
|
|
109
|
+
import larrylint from 'larrylint'
|
|
110
|
+
|
|
111
|
+
export default createConfigForNuxt().append(larrylint())
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
With a plain array, like `@laioutr/eslint-config`:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
import config from '@laioutr/eslint-config/nuxt-module'
|
|
118
|
+
import larrylint from 'larrylint'
|
|
119
|
+
|
|
120
|
+
export default [...config, ...(await larrylint())]
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The preset only brings rules, no parsers or style rules, so it runs on top of whatever your config already uses.
|
|
124
|
+
|
|
125
|
+
## CLI
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
larrylint [--cwd <folder>] [--fix] [--baseline]
|
|
129
|
+
larrylint init [--cwd <folder>]
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- `--fix` fixes what can be fixed automatically.
|
|
133
|
+
- `--baseline` records the current violations in `larrylint-baseline.json`.
|
|
134
|
+
|
|
135
|
+
The CLI only runs larrylint's rules, independent of your ESLint setup, and exits with code 1 on new violations.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
Published under the [MIT License](https://github.com/bussmann-io/larrylint/tree/main/LICENSE).
|
|
140
|
+
|
|
141
|
+
[github-actions-src]: https://github.com/bussmann-io/larrylint/actions/workflows/test.yml/badge.svg
|
|
142
|
+
[github-actions-href]: https://github.com/bussmann-io/larrylint/actions
|
|
143
|
+
|
|
144
|
+
[npm-version-src]: https://img.shields.io/npm/v/larrylint/latest.svg?style=flat&colorA=18181B&colorB=31C553
|
|
145
|
+
[npm-version-href]: https://npmjs.com/package/larrylint
|
|
146
|
+
|
|
147
|
+
[npm-last-update-src]: https://img.shields.io/npm/last-update/larrylint.svg?style=flat&colorA=18181B&colorB=31C553
|
|
148
|
+
[npm-last-update-href]: https://npmjs.com/package/larrylint
|
|
149
|
+
|
|
150
|
+
[license-src]: https://img.shields.io/github/license/bussmann-io/larrylint.svg?style=flat&colorA=18181B&colorB=31C553
|
|
151
|
+
[license-href]: https://github.com/bussmann-io/larrylint/tree/main/LICENSE
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { larrylint, readBaseline } from "./preset.mjs";
|
|
2
|
+
import { relative } from "pathe";
|
|
3
|
+
import tsParser from "@typescript-eslint/parser";
|
|
4
|
+
import { ESLint } from "eslint";
|
|
5
|
+
import vueParser from "vue-eslint-parser";
|
|
6
|
+
const PARSERS = [
|
|
7
|
+
{ ignores: ["**/*.d.ts"] },
|
|
8
|
+
{
|
|
9
|
+
files: ["**/*.{ts,mts,cts}"],
|
|
10
|
+
languageOptions: { parser: tsParser }
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
files: ["**/*.vue"],
|
|
14
|
+
languageOptions: {
|
|
15
|
+
parser: vueParser,
|
|
16
|
+
parserOptions: {
|
|
17
|
+
parser: tsParser,
|
|
18
|
+
extraFileExtensions: [".vue"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
];
|
|
23
|
+
async function check(cwd, options = {}) {
|
|
24
|
+
const eslint = new ESLint({
|
|
25
|
+
cwd,
|
|
26
|
+
overrideConfigFile: true,
|
|
27
|
+
overrideConfig: [
|
|
28
|
+
...PARSERS,
|
|
29
|
+
...await larrylint({ cwd }),
|
|
30
|
+
{
|
|
31
|
+
linterOptions: { reportUnusedDisableDirectives: "off" },
|
|
32
|
+
settings: { larrylint: { baseline: false } }
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
fix: options.fix,
|
|
36
|
+
errorOnUnmatchedPattern: false
|
|
37
|
+
});
|
|
38
|
+
const results = await eslint.lintFiles(["src"]);
|
|
39
|
+
if (options.fix) await ESLint.outputFixes(results);
|
|
40
|
+
return {
|
|
41
|
+
eslint,
|
|
42
|
+
...applyBaseline(cwd, results)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function applyBaseline(cwd, results) {
|
|
46
|
+
const baseline = readBaseline(cwd);
|
|
47
|
+
const violations = {};
|
|
48
|
+
let baselined = 0;
|
|
49
|
+
let improved = 0;
|
|
50
|
+
const filtered = results.map((result) => {
|
|
51
|
+
const path = relative(cwd, result.filePath);
|
|
52
|
+
const perRule = {};
|
|
53
|
+
const relevant = result.messages.filter((message) => message.fatal || message.ruleId?.startsWith("larrylint/"));
|
|
54
|
+
for (const { ruleId } of relevant) if (ruleId) perRule[ruleId] = (perRule[ruleId] ?? 0) + 1;
|
|
55
|
+
if (Object.keys(perRule).length > 0) violations[path] = perRule;
|
|
56
|
+
const messages = relevant.filter(({ ruleId }) => !ruleId || perRule[ruleId] > (baseline[path]?.[ruleId] ?? 0));
|
|
57
|
+
baselined += relevant.length - messages.length;
|
|
58
|
+
return {
|
|
59
|
+
...result,
|
|
60
|
+
messages,
|
|
61
|
+
errorCount: messages.filter((message) => message.severity === 2).length,
|
|
62
|
+
fatalErrorCount: messages.filter((message) => message.fatal).length,
|
|
63
|
+
warningCount: messages.filter((message) => message.severity === 1).length,
|
|
64
|
+
fixableErrorCount: messages.filter((message) => message.severity === 2 && message.fix).length,
|
|
65
|
+
fixableWarningCount: messages.filter((message) => message.severity === 1 && message.fix).length
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
for (const [path, rules] of Object.entries(baseline)) for (const [rule, allowed] of Object.entries(rules)) if ((violations[path]?.[rule] ?? 0) < allowed) improved++;
|
|
69
|
+
return {
|
|
70
|
+
results: filtered,
|
|
71
|
+
violations,
|
|
72
|
+
baselined,
|
|
73
|
+
improved
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export { check };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { BASELINE_FILE, countViolations, writeBaseline } from "./preset.mjs";
|
|
2
|
+
import { cwdArgs } from "../cli/index.mjs";
|
|
3
|
+
import { check } from "./check.mjs";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { join, resolve } from "pathe";
|
|
7
|
+
import { defineCommand } from "citty";
|
|
8
|
+
import { consola } from "consola";
|
|
9
|
+
var check_default = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: "check",
|
|
12
|
+
description: "Check the Laioutr app in the current folder."
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
...cwdArgs,
|
|
16
|
+
fix: {
|
|
17
|
+
type: "boolean",
|
|
18
|
+
description: "Fix what can be fixed automatically."
|
|
19
|
+
},
|
|
20
|
+
baseline: {
|
|
21
|
+
type: "boolean",
|
|
22
|
+
description: `Record all current violations in ${BASELINE_FILE}, so only new ones fail.`
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
run: async ({ args }) => {
|
|
26
|
+
const cwd = resolve(args.cwd);
|
|
27
|
+
if (!existsSync(join(cwd, "src/runtime"))) {
|
|
28
|
+
consola.warn(`No src/runtime/ in ${cwd}. larrylint checks Laioutr apps, run it in the app's folder.`);
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const { eslint, results, violations, baselined, improved } = await check(cwd, { fix: args.fix });
|
|
33
|
+
if (args.baseline) {
|
|
34
|
+
writeBaseline(cwd, violations);
|
|
35
|
+
const total = countViolations(violations);
|
|
36
|
+
consola.success(total > 0 ? `Recorded ${total} violations in ${Object.keys(violations).length} files in ${BASELINE_FILE}.` : "No violations, nothing to record.");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const output = await (await eslint.loadFormatter("stylish")).format(results);
|
|
40
|
+
if (output) process.stdout.write(`${output}\n`);
|
|
41
|
+
if (baselined > 0) consola.info(`${baselined} known violations are recorded in ${BASELINE_FILE}.`);
|
|
42
|
+
if (improved > 0) consola.info(`${improved} baseline entries have fewer violations now. Run larrylint --baseline to lock that in.`);
|
|
43
|
+
if (results.some((result) => result.errorCount > 0)) {
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
consola.success(baselined > 0 ? "No new violations." : "No violations.");
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
export { check_default as default };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { BASELINE_FILE, countViolations, writeBaseline } from "./preset.mjs";
|
|
2
|
+
import { cwdArgs } from "../cli/index.mjs";
|
|
3
|
+
import { check } from "./check.mjs";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join, relative, resolve } from "pathe";
|
|
7
|
+
import { defineCommand } from "citty";
|
|
8
|
+
import { consola } from "consola";
|
|
9
|
+
import { addDevDependency, detectPackageManager } from "nypm";
|
|
10
|
+
import { readPackageJSON } from "pkg-types";
|
|
11
|
+
import { parseModule } from "magicast";
|
|
12
|
+
const CONFIG_FILES = [
|
|
13
|
+
"eslint.config.js",
|
|
14
|
+
"eslint.config.mjs",
|
|
15
|
+
"eslint.config.cjs",
|
|
16
|
+
"eslint.config.ts",
|
|
17
|
+
"eslint.config.mts",
|
|
18
|
+
"eslint.config.cts"
|
|
19
|
+
];
|
|
20
|
+
const COMPOSERS = /^(?:@nuxt\/eslint-config(?:\/flat)?|@antfu\/eslint-config|eslint-flat-config-utils)$|\/\.nuxt\/eslint\.config\.mjs$/;
|
|
21
|
+
const CONFIG_FUNCTIONS = /* @__PURE__ */ new Set(["defineConfig", "extendConfig"]);
|
|
22
|
+
const NEW_ESLINT_CONFIG = `import larrylint from 'larrylint'\n\nexport default larrylint()\n`;
|
|
23
|
+
const MANUAL_SNIPPET = `import larrylint from 'larrylint'
|
|
24
|
+
|
|
25
|
+
export default [
|
|
26
|
+
// ...your config
|
|
27
|
+
...(await larrylint()),
|
|
28
|
+
]`;
|
|
29
|
+
function wireEslintConfig(cwd) {
|
|
30
|
+
const file = CONFIG_FILES.map((name) => join(cwd, name)).find((path) => existsSync(path));
|
|
31
|
+
if (!file) {
|
|
32
|
+
const created = join(cwd, "eslint.config.mjs");
|
|
33
|
+
writeFileSync(created, NEW_ESLINT_CONFIG);
|
|
34
|
+
return {
|
|
35
|
+
status: "created",
|
|
36
|
+
file: created
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const result = addLarrylint(readFileSync(file, "utf8"));
|
|
40
|
+
if (typeof result === "string") {
|
|
41
|
+
writeFileSync(file, result);
|
|
42
|
+
return {
|
|
43
|
+
status: "updated",
|
|
44
|
+
file
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
status: result,
|
|
49
|
+
file
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function addLarrylint(code) {
|
|
53
|
+
const mod = parseModule(code);
|
|
54
|
+
const imports = Object.values(mod.imports);
|
|
55
|
+
if (imports.some((item) => item.from === "larrylint")) return "present";
|
|
56
|
+
const body = mod.$ast.body;
|
|
57
|
+
const exported = body.find((node) => node.type === "ExportDefaultDeclaration")?.declaration;
|
|
58
|
+
let edit;
|
|
59
|
+
if (exported?.type === "ArrayExpression") edit = appendItem(code, exported, exported.elements, "...(await larrylint())");
|
|
60
|
+
else if (exported?.type === "CallExpression" && isComposer(exported, imports)) {
|
|
61
|
+
const indent = code.slice(exported.start, exported.end).match(/\n([ \t]*)\.[a-z]/i)?.[1];
|
|
62
|
+
edit = {
|
|
63
|
+
start: exported.end,
|
|
64
|
+
end: exported.end,
|
|
65
|
+
text: `${indent === void 0 ? "" : `\n${indent}`}.append(larrylint())`
|
|
66
|
+
};
|
|
67
|
+
} else if (exported?.type === "CallExpression" && exported.callee.type === "Identifier" && CONFIG_FUNCTIONS.has(exported.callee.name)) edit = appendItem(code, exported, exported.arguments, "await larrylint()");
|
|
68
|
+
else if (exported?.type === "Identifier") edit = extendBinding(body, exported, imports);
|
|
69
|
+
if (!edit) return "unknown";
|
|
70
|
+
const lastImport = body.filter((node) => node.type === "ImportDeclaration").at(-1);
|
|
71
|
+
const importLine = `import larrylint from 'larrylint'${(lastImport ? code.slice(lastImport.start, lastImport.end).endsWith(";") : false) ? ";" : ""}`;
|
|
72
|
+
let result = `${code.slice(0, edit.start)}${edit.text}${code.slice(edit.end)}`;
|
|
73
|
+
result = lastImport ? `${result.slice(0, lastImport.end)}\n${importLine}${result.slice(lastImport.end)}` : `${importLine}\n\n${result}`;
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
function extendBinding(body, identifier, imports) {
|
|
77
|
+
const imported = imports.find((item) => item.local === identifier.name);
|
|
78
|
+
const declared = body.flatMap((node) => node.type === "VariableDeclaration" ? node.declarations : []).find((declarator) => declarator.id.name === identifier.name);
|
|
79
|
+
return (imported ? COMPOSERS.test(imported.from) : declared?.init?.type === "CallExpression" && isComposer(declared.init, imports)) ? {
|
|
80
|
+
start: identifier.end,
|
|
81
|
+
end: identifier.end,
|
|
82
|
+
text: ".append(larrylint())"
|
|
83
|
+
} : {
|
|
84
|
+
start: identifier.start,
|
|
85
|
+
end: identifier.end,
|
|
86
|
+
text: `[...${identifier.name}, ...(await larrylint())]`
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function isComposer(call, imports) {
|
|
90
|
+
let node = call;
|
|
91
|
+
while (node.callee.type === "MemberExpression" && node.callee.object.type === "CallExpression") node = node.callee.object;
|
|
92
|
+
const factory = node.callee.type === "Identifier" ? imports.find((item) => item.local === node.callee.name) : void 0;
|
|
93
|
+
return factory ? COMPOSERS.test(factory.from) : false;
|
|
94
|
+
}
|
|
95
|
+
function appendItem(code, container, items, text) {
|
|
96
|
+
const closing = container.end - 1;
|
|
97
|
+
const last = items.at(-1);
|
|
98
|
+
const insert = (at, insertion) => ({
|
|
99
|
+
start: at,
|
|
100
|
+
end: at,
|
|
101
|
+
text: insertion
|
|
102
|
+
});
|
|
103
|
+
if (!last) return insert(closing, text);
|
|
104
|
+
if (!code.slice(container.start, container.end).includes("\n")) return insert(last.end, `, ${text}`);
|
|
105
|
+
const indent = code.slice(code.lastIndexOf("\n", last.start) + 1, last.start).match(/^[ \t]*/)[0];
|
|
106
|
+
const comma = code.indexOf(",", last.end);
|
|
107
|
+
return comma !== -1 && comma < closing ? insert(comma + 1, `\n${indent}${text},`) : insert(last.end, `,\n${indent}${text}`);
|
|
108
|
+
}
|
|
109
|
+
var init_default = defineCommand({
|
|
110
|
+
meta: {
|
|
111
|
+
name: "init",
|
|
112
|
+
description: "Set up larrylint in the Laioutr app in the current folder."
|
|
113
|
+
},
|
|
114
|
+
args: cwdArgs,
|
|
115
|
+
run: async ({ args }) => {
|
|
116
|
+
const cwd = resolve(args.cwd);
|
|
117
|
+
if (!existsSync(join(cwd, "package.json")) || !existsSync(join(cwd, "src/runtime"))) {
|
|
118
|
+
consola.error(`${cwd} doesn't look like a Laioutr app: it needs a package.json and a src/runtime/ folder.`);
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const pkg = await readPackageJSON(cwd);
|
|
123
|
+
if (pkg.dependencies?.larrylint || pkg.devDependencies?.larrylint) consola.info("larrylint is already installed.");
|
|
124
|
+
else {
|
|
125
|
+
const packageManager = await detectPackageManager(cwd);
|
|
126
|
+
const workspace = packageManager?.name === "pnpm" ? existsSync(join(cwd, "pnpm-workspace.yaml")) : packageManager?.name === "yarn" && Boolean(pkg.workspaces);
|
|
127
|
+
consola.start("Installing larrylint...");
|
|
128
|
+
await addDevDependency("larrylint", {
|
|
129
|
+
cwd,
|
|
130
|
+
packageManager,
|
|
131
|
+
workspace,
|
|
132
|
+
silent: true
|
|
133
|
+
});
|
|
134
|
+
consola.success("Installed larrylint.");
|
|
135
|
+
}
|
|
136
|
+
const wired = wireEslintConfig(cwd);
|
|
137
|
+
const file = relative(cwd, wired.file);
|
|
138
|
+
if (wired.status === "created") consola.success(`Created ${file} with the larrylint rules.`);
|
|
139
|
+
else if (wired.status === "updated") consola.success(`Added the larrylint rules to ${file}.`);
|
|
140
|
+
else if (wired.status === "present") consola.info(`${file} already uses larrylint.`);
|
|
141
|
+
else consola.warn(`Couldn't add larrylint to ${file} automatically. Add it like this:\n\n${MANUAL_SNIPPET}\n`);
|
|
142
|
+
const { violations } = await check(cwd);
|
|
143
|
+
writeBaseline(cwd, violations);
|
|
144
|
+
const total = countViolations(violations);
|
|
145
|
+
if (total > 0) consola.info(`Recorded ${total} existing violations in ${Object.keys(violations).length} files in ${BASELINE_FILE}. New code has to follow the rules; run larrylint --baseline after fixing old violations.`);
|
|
146
|
+
else consola.success("No violations found.");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
export { init_default as default };
|
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import { name, version } from "./package.mjs";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { loadConfig } from "c12";
|
|
4
|
+
import { readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, join, normalize, resolve } from "pathe";
|
|
6
|
+
function defineLarrylintConfig(config) {
|
|
7
|
+
return config;
|
|
8
|
+
}
|
|
9
|
+
async function loadLarrylintConfig(cwd = process.cwd()) {
|
|
10
|
+
const { config } = await loadConfig({
|
|
11
|
+
name: "larrylint",
|
|
12
|
+
cwd,
|
|
13
|
+
packageJson: true,
|
|
14
|
+
rcFile: false,
|
|
15
|
+
globalRc: false,
|
|
16
|
+
dotenv: false
|
|
17
|
+
});
|
|
18
|
+
return { sharedDomains: config.sharedDomains ?? [] };
|
|
19
|
+
}
|
|
20
|
+
const BASELINE_FILE = "larrylint-baseline.json";
|
|
21
|
+
const cache = /* @__PURE__ */ new Map();
|
|
22
|
+
function readBaseline(root) {
|
|
23
|
+
const file = join(root, BASELINE_FILE);
|
|
24
|
+
let mtimeMs;
|
|
25
|
+
try {
|
|
26
|
+
mtimeMs = statSync(file).mtimeMs;
|
|
27
|
+
} catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
const cached = cache.get(file);
|
|
31
|
+
if (cached?.mtimeMs === mtimeMs) return cached.baseline;
|
|
32
|
+
const baseline = JSON.parse(readFileSync(file, "utf8"));
|
|
33
|
+
cache.set(file, {
|
|
34
|
+
mtimeMs,
|
|
35
|
+
baseline
|
|
36
|
+
});
|
|
37
|
+
return baseline;
|
|
38
|
+
}
|
|
39
|
+
function writeBaseline(root, baseline) {
|
|
40
|
+
const file = join(root, BASELINE_FILE);
|
|
41
|
+
const files = Object.keys(baseline).sort();
|
|
42
|
+
if (files.length === 0) {
|
|
43
|
+
rmSync(file, { force: true });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const sorted = {};
|
|
47
|
+
for (const path of files) sorted[path] = Object.fromEntries(Object.entries(baseline[path]).sort(([a], [b]) => a.localeCompare(b)));
|
|
48
|
+
writeFileSync(file, `${JSON.stringify(sorted, null, 2)}\n`);
|
|
49
|
+
}
|
|
50
|
+
function countViolations(baseline) {
|
|
51
|
+
return Object.values(baseline).flatMap((rules) => Object.values(rules)).reduce((sum, count) => sum + count, 0);
|
|
52
|
+
}
|
|
53
|
+
function createReporter(context, file) {
|
|
54
|
+
const reports = [];
|
|
55
|
+
return {
|
|
56
|
+
report: (descriptor) => {
|
|
57
|
+
reports.push(descriptor);
|
|
58
|
+
},
|
|
59
|
+
flush: () => {
|
|
60
|
+
const settings = context.settings.larrylint;
|
|
61
|
+
const allowed = file && settings?.baseline !== false ? readBaseline(file.root)[file.path]?.[context.id] ?? 0 : 0;
|
|
62
|
+
if (reports.length > allowed) for (const descriptor of reports) context.report(descriptor);
|
|
63
|
+
reports.length = 0;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const directories = /* @__PURE__ */ new Map();
|
|
68
|
+
function isDirectory(path) {
|
|
69
|
+
let result = directories.get(path);
|
|
70
|
+
if (result === void 0) {
|
|
71
|
+
try {
|
|
72
|
+
result = statSync(path).isDirectory();
|
|
73
|
+
} catch {
|
|
74
|
+
result = false;
|
|
75
|
+
}
|
|
76
|
+
directories.set(path, result);
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
function resolveImport(importer, source) {
|
|
81
|
+
if (!source.startsWith("./") && !source.startsWith("../")) return;
|
|
82
|
+
const target = resolve(dirname(importer), source.split("?")[0]);
|
|
83
|
+
return isDirectory(target) ? `${target}/index` : target;
|
|
84
|
+
}
|
|
85
|
+
const HANDLER_FILE = /\.(?:query|resolver|link|action|template|page-index)(?:\.[cm]?[jt]s)?$/;
|
|
86
|
+
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
87
|
+
const APP_FOLDERS = {
|
|
88
|
+
sections: "section",
|
|
89
|
+
blocks: "block",
|
|
90
|
+
components: "component",
|
|
91
|
+
composables: "composable",
|
|
92
|
+
utils: "app-util",
|
|
93
|
+
plugins: "app-plugin",
|
|
94
|
+
overrides: "override"
|
|
95
|
+
};
|
|
96
|
+
const SERVER_FOLDERS = {
|
|
97
|
+
"middleware": "middleware",
|
|
98
|
+
"client": "client",
|
|
99
|
+
"utils": "server-util",
|
|
100
|
+
"api": "route",
|
|
101
|
+
"routes": "route",
|
|
102
|
+
"plugins": "nitro-plugin",
|
|
103
|
+
"media-library": "media-library",
|
|
104
|
+
"media-libraries": "media-library"
|
|
105
|
+
};
|
|
106
|
+
function classify(file) {
|
|
107
|
+
const absolute = normalize(file);
|
|
108
|
+
const test = TEST_FILE.test(absolute);
|
|
109
|
+
const runtimeIndex = absolute.lastIndexOf("/src/runtime/");
|
|
110
|
+
if (runtimeIndex !== -1) {
|
|
111
|
+
const root = absolute.slice(0, runtimeIndex);
|
|
112
|
+
return {
|
|
113
|
+
root,
|
|
114
|
+
path: absolute.slice(root.length + 1),
|
|
115
|
+
test,
|
|
116
|
+
...classifyRuntime(absolute.slice(runtimeIndex + 13).split("/"))
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const srcIndex = absolute.lastIndexOf("/src/");
|
|
120
|
+
if (srcIndex === -1) return;
|
|
121
|
+
const root = absolute.slice(0, srcIndex);
|
|
122
|
+
const path = absolute.slice(root.length + 1);
|
|
123
|
+
return {
|
|
124
|
+
root,
|
|
125
|
+
path,
|
|
126
|
+
side: path.startsWith("src/types/") ? "other" : "build",
|
|
127
|
+
test
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function domainOf(file) {
|
|
131
|
+
if (!file.domain || file.kind === "handler" || file.kind === "orchestr-file") return file.domain;
|
|
132
|
+
return isDirectory(`${file.root}/src/runtime/server/orchestr/${file.domain}`) ? file.domain : void 0;
|
|
133
|
+
}
|
|
134
|
+
function classifyRuntime(parts) {
|
|
135
|
+
const [side, folder = "", ...rest] = parts;
|
|
136
|
+
if (parts.length < 2 || side !== "app" && side !== "server" && side !== "shared") return { side: "other" };
|
|
137
|
+
if (side === "shared") return {
|
|
138
|
+
side,
|
|
139
|
+
kind: "shared"
|
|
140
|
+
};
|
|
141
|
+
if (side === "app") return {
|
|
142
|
+
side,
|
|
143
|
+
kind: APP_FOLDERS[folder] ?? "other"
|
|
144
|
+
};
|
|
145
|
+
const nested = rest.length > 1;
|
|
146
|
+
if (folder === "orchestr") {
|
|
147
|
+
if (nested && rest[0] === "plugins") return {
|
|
148
|
+
side,
|
|
149
|
+
kind: "orchestr-plugin"
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
side,
|
|
153
|
+
kind: HANDLER_FILE.test(parts.at(-1)) ? "handler" : "orchestr-file",
|
|
154
|
+
domain: nested ? rest[0] : void 0
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const kind = SERVER_FOLDERS[folder] ?? "other";
|
|
158
|
+
return {
|
|
159
|
+
side,
|
|
160
|
+
kind,
|
|
161
|
+
domain: kind === "server-util" && nested ? rest[0] : void 0
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function kebabCase(name) {
|
|
165
|
+
return name.replace(/([a-z\d])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
|
|
166
|
+
}
|
|
167
|
+
function prefixName(name, prefix) {
|
|
168
|
+
return `${prefix}${name.endsWith(prefix) ? name.slice(0, -prefix.length) : name}`;
|
|
169
|
+
}
|
|
170
|
+
const UI_KIT_BUTTON = /(?:^#ui-kit|@laioutr-core\/ui-kit)\/.*\/Button\.vue$/;
|
|
171
|
+
const button = {
|
|
172
|
+
meta: {
|
|
173
|
+
type: "problem",
|
|
174
|
+
fixable: "code",
|
|
175
|
+
docs: { description: "Disallow `type` on the ui-kit button, which silently renders its `button-type` prop instead." },
|
|
176
|
+
schema: [],
|
|
177
|
+
messages: { buttonType: "l-button ignores type and always renders its button-type prop, which defaults to \"button\". Use button-type." }
|
|
178
|
+
},
|
|
179
|
+
create(context) {
|
|
180
|
+
const services = context.sourceCode.parserServices;
|
|
181
|
+
if (!services?.defineTemplateBodyVisitor) return {};
|
|
182
|
+
const file = classify(context.filename);
|
|
183
|
+
if (file?.test) return {};
|
|
184
|
+
const reporter = createReporter(context, file);
|
|
185
|
+
const buttons = /* @__PURE__ */ new Set(["LButton", "l-button"]);
|
|
186
|
+
return services.defineTemplateBodyVisitor({
|
|
187
|
+
"VElement": (node) => {
|
|
188
|
+
if (!buttons.has(node.rawName)) return;
|
|
189
|
+
for (const attribute of node.startTag.attributes) {
|
|
190
|
+
if (!attribute.directive && attribute.key.rawName === "type") {
|
|
191
|
+
const key = attribute.key;
|
|
192
|
+
reporter.report({
|
|
193
|
+
loc: key.loc,
|
|
194
|
+
messageId: "buttonType",
|
|
195
|
+
fix: (fixer) => fixer.replaceTextRange(key.range, "button-type")
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const argument = attribute.directive ? attribute.key.argument : null;
|
|
199
|
+
if (attribute.directive && attribute.key.name.name === "bind" && argument?.type === "VIdentifier" && argument.rawName === "type") reporter.report({
|
|
200
|
+
loc: argument.loc,
|
|
201
|
+
messageId: "buttonType",
|
|
202
|
+
fix: (fixer) => fixer.replaceTextRange(argument.range, "button-type")
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
"VElement:exit": (node) => {
|
|
207
|
+
if (node.parent.type === "VDocumentFragment") reporter.flush();
|
|
208
|
+
}
|
|
209
|
+
}, { ImportDeclaration: (node) => {
|
|
210
|
+
if (typeof node.source.value !== "string" || !UI_KIT_BUTTON.test(node.source.value)) return;
|
|
211
|
+
for (const specifier of node.specifiers) if (specifier.type === "ImportDefaultSpecifier") {
|
|
212
|
+
const kebab = kebabCase(specifier.local.name);
|
|
213
|
+
buttons.add(specifier.local.name);
|
|
214
|
+
if (kebab.includes("-")) buttons.add(kebab);
|
|
215
|
+
}
|
|
216
|
+
} });
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const FILE_START = {
|
|
220
|
+
start: {
|
|
221
|
+
line: 1,
|
|
222
|
+
column: 0
|
|
223
|
+
},
|
|
224
|
+
end: {
|
|
225
|
+
line: 1,
|
|
226
|
+
column: 0
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
function findProperty(object, key) {
|
|
230
|
+
for (const entry of object.properties) if (entry.type === "Property" && !entry.computed && (entry.key.type === "Identifier" && entry.key.name === key || entry.key.type === "Literal" && entry.key.value === key)) return entry.value;
|
|
231
|
+
}
|
|
232
|
+
function findStringProperty(object, key) {
|
|
233
|
+
const value = findProperty(object, key);
|
|
234
|
+
return value?.type === "Literal" && typeof value.value === "string" ? {
|
|
235
|
+
value: value.value,
|
|
236
|
+
node: value
|
|
237
|
+
} : void 0;
|
|
238
|
+
}
|
|
239
|
+
function objectElements(node) {
|
|
240
|
+
if (node?.type !== "ArrayExpression") return [];
|
|
241
|
+
return node.elements.filter((element) => element?.type === "ObjectExpression");
|
|
242
|
+
}
|
|
243
|
+
const DEFINERS = {
|
|
244
|
+
defineSection: {
|
|
245
|
+
kind: "section",
|
|
246
|
+
folder: "sections",
|
|
247
|
+
prefix: "Section"
|
|
248
|
+
},
|
|
249
|
+
defineBlock: {
|
|
250
|
+
kind: "block",
|
|
251
|
+
folder: "blocks",
|
|
252
|
+
prefix: "Block"
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
const FORBIDDEN_FIELD_NAMES = /* @__PURE__ */ new Set([
|
|
256
|
+
"style",
|
|
257
|
+
"class",
|
|
258
|
+
"key",
|
|
259
|
+
"ref",
|
|
260
|
+
"is",
|
|
261
|
+
"slot",
|
|
262
|
+
"refFor",
|
|
263
|
+
"refKey"
|
|
264
|
+
]);
|
|
265
|
+
const definition = {
|
|
266
|
+
meta: {
|
|
267
|
+
type: "problem",
|
|
268
|
+
docs: { description: "Keep defineSection() in app/sections/Section*.vue and defineBlock() in app/blocks/Block*.vue, with a matching component name and schema fields that reach the component." },
|
|
269
|
+
schema: [],
|
|
270
|
+
messages: {
|
|
271
|
+
notDefined: "Laioutr registers every .vue in {{folder}}/ as a Studio {{kind}}, but this file has no {{definer}}(). Move it to components/.",
|
|
272
|
+
wrongFolder: "{{definer}}() belongs in app/{{folder}}/.",
|
|
273
|
+
prefix: "Name {{kind}}s {{prefix}}*.vue, e.g. {{suggestion}}.vue.",
|
|
274
|
+
componentName: "component: '{{actual}}' must match the file name '{{expected}}'.",
|
|
275
|
+
forbiddenFieldName: "Vue consumes '{{name}}' before it reaches the component, so this field never arrives as a prop. Pick another name, e.g. variant for a style selector."
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
create(context) {
|
|
279
|
+
const file = classify(context.filename);
|
|
280
|
+
if (!file || file.test || file.side !== "app") return {};
|
|
281
|
+
const reporter = createReporter(context, file);
|
|
282
|
+
const name = basename(file.path).replace(/\.[^.]+$/, "");
|
|
283
|
+
const calls = [];
|
|
284
|
+
return {
|
|
285
|
+
"CallExpression": (node) => {
|
|
286
|
+
if (node.callee.type === "Identifier" && Object.hasOwn(DEFINERS, node.callee.name)) calls.push({
|
|
287
|
+
definer: node.callee.name,
|
|
288
|
+
node
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
"Program:exit": () => {
|
|
292
|
+
const expected = Object.keys(DEFINERS).find((definer) => DEFINERS[definer].kind === file.kind);
|
|
293
|
+
if (expected && file.path.endsWith(".vue") && !calls.some((call) => call.definer === expected)) {
|
|
294
|
+
const { kind, folder } = DEFINERS[expected];
|
|
295
|
+
reporter.report({
|
|
296
|
+
loc: FILE_START,
|
|
297
|
+
messageId: "notDefined",
|
|
298
|
+
data: {
|
|
299
|
+
folder,
|
|
300
|
+
kind,
|
|
301
|
+
definer: expected
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
for (const { definer, node } of calls) {
|
|
306
|
+
const { kind, folder, prefix } = DEFINERS[definer];
|
|
307
|
+
if (file.kind !== kind && file.kind !== "override") reporter.report({
|
|
308
|
+
node: node.callee,
|
|
309
|
+
messageId: "wrongFolder",
|
|
310
|
+
data: {
|
|
311
|
+
definer,
|
|
312
|
+
folder
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
else if (file.kind === kind && !name.startsWith(prefix)) reporter.report({
|
|
316
|
+
node: node.callee,
|
|
317
|
+
messageId: "prefix",
|
|
318
|
+
data: {
|
|
319
|
+
kind,
|
|
320
|
+
prefix,
|
|
321
|
+
suggestion: prefixName(name, prefix)
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
const [options] = node.arguments;
|
|
325
|
+
if (options?.type !== "ObjectExpression") continue;
|
|
326
|
+
const component = findStringProperty(options, "component");
|
|
327
|
+
if (component && component.value !== name) reporter.report({
|
|
328
|
+
node: component.node,
|
|
329
|
+
messageId: "componentName",
|
|
330
|
+
data: {
|
|
331
|
+
actual: component.value,
|
|
332
|
+
expected: name
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
for (const group of objectElements(findProperty(options, "schema"))) for (const field of objectElements(findProperty(group, "fields"))) {
|
|
336
|
+
const fieldName = findStringProperty(field, "name");
|
|
337
|
+
if (fieldName && FORBIDDEN_FIELD_NAMES.has(fieldName.value)) reporter.report({
|
|
338
|
+
node: fieldName.node,
|
|
339
|
+
messageId: "forbiddenFieldName",
|
|
340
|
+
data: { name: fieldName.value }
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
reporter.flush();
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
const files = {
|
|
350
|
+
meta: {
|
|
351
|
+
type: "problem",
|
|
352
|
+
docs: { description: "Keep orchestr/ to handler files in domain folders that only export their handler." },
|
|
353
|
+
schema: [],
|
|
354
|
+
messages: {
|
|
355
|
+
notAHandler: "Laioutr loads every file in orchestr/ as a server plugin. Name handlers *.query.ts, *.resolver.ts, *.link.ts, *.action.ts, *.template.ts or *.page-index.ts, and move everything else to server/utils/.",
|
|
356
|
+
noDomain: "Put handlers in a domain folder, e.g. orchestr/<domain>/{{file}}.",
|
|
357
|
+
namedExport: "Handler files only export their handler. Laioutr registers the default export; move everything else to server/utils/.",
|
|
358
|
+
missingDefault: "Handler files export their handler as default, otherwise laioutr registers nothing."
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
create(context) {
|
|
362
|
+
const file = classify(context.filename);
|
|
363
|
+
if (!file || file.test || file.side !== "server" || file.kind !== "handler" && file.kind !== "orchestr-file") return {};
|
|
364
|
+
const reporter = createReporter(context, file);
|
|
365
|
+
if (file.kind === "orchestr-file") return { "Program:exit": () => {
|
|
366
|
+
reporter.report({
|
|
367
|
+
loc: FILE_START,
|
|
368
|
+
messageId: "notAHandler"
|
|
369
|
+
});
|
|
370
|
+
reporter.flush();
|
|
371
|
+
} };
|
|
372
|
+
let hasDefault = false;
|
|
373
|
+
return {
|
|
374
|
+
"ExportDefaultDeclaration": () => {
|
|
375
|
+
hasDefault = true;
|
|
376
|
+
},
|
|
377
|
+
"ExportNamedDeclaration": (node) => {
|
|
378
|
+
const others = node.specifiers.filter((specifier) => {
|
|
379
|
+
const name = specifier.exported.type === "Identifier" ? specifier.exported.name : specifier.exported.value;
|
|
380
|
+
if (name === "default") hasDefault = true;
|
|
381
|
+
return name !== "default";
|
|
382
|
+
});
|
|
383
|
+
if (node.declaration || others.length > 0) reporter.report({
|
|
384
|
+
node,
|
|
385
|
+
messageId: "namedExport"
|
|
386
|
+
});
|
|
387
|
+
},
|
|
388
|
+
"ExportAllDeclaration": (node) => {
|
|
389
|
+
reporter.report({
|
|
390
|
+
node,
|
|
391
|
+
messageId: "namedExport"
|
|
392
|
+
});
|
|
393
|
+
},
|
|
394
|
+
"Program:exit": () => {
|
|
395
|
+
if (!file.domain) reporter.report({
|
|
396
|
+
loc: FILE_START,
|
|
397
|
+
messageId: "noDomain",
|
|
398
|
+
data: { file: basename(file.path) }
|
|
399
|
+
});
|
|
400
|
+
if (!hasDefault) reporter.report({
|
|
401
|
+
loc: FILE_START,
|
|
402
|
+
messageId: "missingDefault"
|
|
403
|
+
});
|
|
404
|
+
reporter.flush();
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
function isTypeOnly(node) {
|
|
410
|
+
const { importKind, exportKind } = node;
|
|
411
|
+
if (importKind === "type" || exportKind === "type") return true;
|
|
412
|
+
return node.type === "ImportDeclaration" && node.specifiers.length > 0 && node.specifiers.every((specifier) => specifier.importKind === "type");
|
|
413
|
+
}
|
|
414
|
+
const LABELS = {
|
|
415
|
+
"section": "section",
|
|
416
|
+
"block": "block",
|
|
417
|
+
"component": "component",
|
|
418
|
+
"composable": "composable",
|
|
419
|
+
"app-plugin": "plugin",
|
|
420
|
+
"override": "override",
|
|
421
|
+
"route": "API route",
|
|
422
|
+
"nitro-plugin": "Nitro plugin",
|
|
423
|
+
"orchestr-plugin": "orchestr plugin",
|
|
424
|
+
"orchestr-file": "orchestr",
|
|
425
|
+
"media-library": "media library"
|
|
426
|
+
};
|
|
427
|
+
const ABOVE_APP_UTILS = /* @__PURE__ */ new Set([
|
|
428
|
+
"section",
|
|
429
|
+
"block",
|
|
430
|
+
"component",
|
|
431
|
+
"composable",
|
|
432
|
+
"app-plugin",
|
|
433
|
+
"override"
|
|
434
|
+
]);
|
|
435
|
+
const ABOVE_COMPOSABLES = /* @__PURE__ */ new Set([
|
|
436
|
+
"section",
|
|
437
|
+
"block",
|
|
438
|
+
"component",
|
|
439
|
+
"override"
|
|
440
|
+
]);
|
|
441
|
+
const ABOVE_SERVER_UTILS = /* @__PURE__ */ new Set([
|
|
442
|
+
"route",
|
|
443
|
+
"nitro-plugin",
|
|
444
|
+
"orchestr-plugin",
|
|
445
|
+
"orchestr-file",
|
|
446
|
+
"media-library"
|
|
447
|
+
]);
|
|
448
|
+
const BUILDER_USERS = /* @__PURE__ */ new Set([
|
|
449
|
+
"handler",
|
|
450
|
+
"orchestr-file",
|
|
451
|
+
"middleware",
|
|
452
|
+
"media-library"
|
|
453
|
+
]);
|
|
454
|
+
function findViolation(importer, target, typeOnly, sharedDomains) {
|
|
455
|
+
if (importer.side === "app" && target.side === "server") return { messageId: "appImportsServer" };
|
|
456
|
+
if (importer.side === "server" && target.side === "app") return { messageId: "serverImportsApp" };
|
|
457
|
+
if (importer.side === "shared" && (target.side === "app" || target.side === "server")) return {
|
|
458
|
+
messageId: "sharedImportsSide",
|
|
459
|
+
data: { side: target.side }
|
|
460
|
+
};
|
|
461
|
+
if (target.side === "build" && !typeOnly) return { messageId: "runtimeImportsBuild" };
|
|
462
|
+
if (target.kind === "handler") return { messageId: "handlerImported" };
|
|
463
|
+
if (typeOnly) return;
|
|
464
|
+
if (target.kind === "client" && importer.kind === "handler") return { messageId: "clientInHandler" };
|
|
465
|
+
if (target.kind === "client" && importer.kind === "server-util") return { messageId: "clientInUtil" };
|
|
466
|
+
if (target.kind === "middleware" && !BUILDER_USERS.has(importer.kind)) return { messageId: "middlewareImported" };
|
|
467
|
+
if (importer.kind === "server-util" && ABOVE_SERVER_UTILS.has(target.kind)) return {
|
|
468
|
+
messageId: "serverUtilImportsUp",
|
|
469
|
+
data: { kind: LABELS[target.kind] }
|
|
470
|
+
};
|
|
471
|
+
const from = domainOf(importer);
|
|
472
|
+
const to = domainOf(target);
|
|
473
|
+
if (from && to && from !== to && !sharedDomains.includes(to)) return {
|
|
474
|
+
messageId: "crossDomain",
|
|
475
|
+
data: {
|
|
476
|
+
from,
|
|
477
|
+
to
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
if (importer.kind === "app-util" && ABOVE_APP_UTILS.has(target.kind)) return {
|
|
481
|
+
messageId: "appUtilImportsUp",
|
|
482
|
+
data: { kind: LABELS[target.kind] }
|
|
483
|
+
};
|
|
484
|
+
if (importer.kind === "composable" && ABOVE_COMPOSABLES.has(target.kind)) return {
|
|
485
|
+
messageId: "composableImportsUp",
|
|
486
|
+
data: { kind: LABELS[target.kind] }
|
|
487
|
+
};
|
|
488
|
+
if (importer.kind === "component" && target.kind === "section") return { messageId: "componentImportsSection" };
|
|
489
|
+
}
|
|
490
|
+
const plugin = {
|
|
491
|
+
meta: {
|
|
492
|
+
name,
|
|
493
|
+
version
|
|
494
|
+
},
|
|
495
|
+
rules: {
|
|
496
|
+
"layers": {
|
|
497
|
+
meta: {
|
|
498
|
+
type: "problem",
|
|
499
|
+
docs: { description: "Enforce the layers of a Laioutr app: app, server and shared code, orchestr handlers, middleware, clients, utils and domains." },
|
|
500
|
+
schema: [{
|
|
501
|
+
type: "object",
|
|
502
|
+
properties: { sharedDomains: {
|
|
503
|
+
type: "array",
|
|
504
|
+
items: { type: "string" }
|
|
505
|
+
} },
|
|
506
|
+
additionalProperties: false
|
|
507
|
+
}],
|
|
508
|
+
messages: {
|
|
509
|
+
appImportsServer: "App code can't import server code. Move what both sides need to src/runtime/shared/.",
|
|
510
|
+
serverImportsApp: "Server code can't import app code. Move what both sides need to src/runtime/shared/.",
|
|
511
|
+
sharedImportsSide: "Shared code runs in the app and on the server, so it can't import from {{side}}/.",
|
|
512
|
+
runtimeImportsBuild: "Runtime code can't import build-time code from src/; it isn't part of the runtime bundle. Type imports are fine.",
|
|
513
|
+
handlerImported: "Orchestr handlers are registered by laioutr, never imported. Move the shared code to server/utils/.",
|
|
514
|
+
clientInHandler: "Handlers read API clients from the orchestr context. server/client/ only holds clients; move constants and helpers to server/utils/.",
|
|
515
|
+
clientInUtil: "Server utils get API clients passed in. server/client/ only holds clients; move constants and helpers to server/utils/.",
|
|
516
|
+
middlewareImported: "Only orchestr handlers and media libraries import middleware. Move helpers like this to server/utils/.",
|
|
517
|
+
serverUtilImportsUp: "Server utils are the bottom layer and can't import {{kind}} code.",
|
|
518
|
+
crossDomain: "The {{from}} domain can't import from the {{to}} domain. Move the shared code out of the {{to}} folders into server/utils/, or add '{{to}}' to sharedDomains.",
|
|
519
|
+
appUtilImportsUp: "App utils are the bottom layer and can't import a {{kind}}. Type imports are fine.",
|
|
520
|
+
composableImportsUp: "Composables can't import a {{kind}}. Type imports are fine.",
|
|
521
|
+
componentImportsSection: "Components can't import sections; sections compose components.",
|
|
522
|
+
nodeBuiltin: "'{{name}}' only exists on the server. Keep it in server/."
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
create(context) {
|
|
526
|
+
const importer = classify(context.filename);
|
|
527
|
+
if (!importer || importer.test || importer.side !== "app" && importer.side !== "server" && importer.side !== "shared") return {};
|
|
528
|
+
const { sharedDomains = [] } = context.options[0] ?? {};
|
|
529
|
+
const reporter = createReporter(context, importer);
|
|
530
|
+
const checkImport = (node, source, typeOnly) => {
|
|
531
|
+
if (typeof source !== "string") return;
|
|
532
|
+
if (source.startsWith("node:")) {
|
|
533
|
+
if (importer.side !== "server" && !typeOnly) reporter.report({
|
|
534
|
+
node,
|
|
535
|
+
messageId: "nodeBuiltin",
|
|
536
|
+
data: { name: source }
|
|
537
|
+
});
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const path = resolveImport(context.filename, source);
|
|
541
|
+
const target = path ? classify(path) : void 0;
|
|
542
|
+
const violation = target && findViolation(importer, target, typeOnly, sharedDomains);
|
|
543
|
+
if (violation) reporter.report({
|
|
544
|
+
node,
|
|
545
|
+
...violation
|
|
546
|
+
});
|
|
547
|
+
};
|
|
548
|
+
return {
|
|
549
|
+
"ImportDeclaration": (node) => {
|
|
550
|
+
checkImport(node, node.source.value, isTypeOnly(node));
|
|
551
|
+
},
|
|
552
|
+
"ExportNamedDeclaration": (node) => {
|
|
553
|
+
if (node.source) checkImport(node, node.source.value, isTypeOnly(node));
|
|
554
|
+
},
|
|
555
|
+
"ExportAllDeclaration": (node) => {
|
|
556
|
+
checkImport(node, node.source.value, isTypeOnly(node));
|
|
557
|
+
},
|
|
558
|
+
"ImportExpression": (node) => {
|
|
559
|
+
if (node.source.type === "Literal") checkImport(node, node.source.value, false);
|
|
560
|
+
},
|
|
561
|
+
"Program:exit": () => {
|
|
562
|
+
reporter.flush();
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
"orchestr-files": files,
|
|
568
|
+
"definitions": definition,
|
|
569
|
+
"button-type": button
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
async function larrylint(options = {}) {
|
|
573
|
+
const { sharedDomains } = await loadLarrylintConfig(options.cwd);
|
|
574
|
+
return [{
|
|
575
|
+
name: "larrylint",
|
|
576
|
+
files: ["**/src/**/*.{ts,mts,cts,js,mjs,cjs,vue}"],
|
|
577
|
+
plugins: { larrylint: plugin },
|
|
578
|
+
rules: {
|
|
579
|
+
"larrylint/layers": ["error", { sharedDomains }],
|
|
580
|
+
"larrylint/orchestr-files": "error",
|
|
581
|
+
"larrylint/definitions": "error",
|
|
582
|
+
"larrylint/button-type": "error"
|
|
583
|
+
}
|
|
584
|
+
}];
|
|
585
|
+
}
|
|
586
|
+
export { BASELINE_FILE, countViolations, defineLarrylintConfig, larrylint, plugin, readBaseline, writeBaseline };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { description, version } from "../_chunks/package.mjs";
|
|
3
|
+
import { defineCommand, runMain } from "citty";
|
|
4
|
+
const cwdArgs = { cwd: {
|
|
5
|
+
type: "string",
|
|
6
|
+
description: "Folder of the Laioutr app.",
|
|
7
|
+
default: "."
|
|
8
|
+
} };
|
|
9
|
+
const main = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: "larrylint",
|
|
12
|
+
version,
|
|
13
|
+
description
|
|
14
|
+
},
|
|
15
|
+
args: cwdArgs,
|
|
16
|
+
subCommands: {
|
|
17
|
+
check: () => import("../_chunks/check2.mjs").then((m) => m.default),
|
|
18
|
+
init: () => import("../_chunks/init.mjs").then((m) => m.default)
|
|
19
|
+
},
|
|
20
|
+
default: "check"
|
|
21
|
+
});
|
|
22
|
+
runMain(main);
|
|
23
|
+
export { cwdArgs };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Linter } from "eslint";
|
|
2
|
+
interface LarrylintConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Domains every other domain may import.
|
|
5
|
+
*/
|
|
6
|
+
sharedDomains?: string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Defines the larrylint configuration of a Laioutr app.
|
|
10
|
+
*
|
|
11
|
+
* @param config The configuration.
|
|
12
|
+
*
|
|
13
|
+
* @returns The configuration, typed.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* // larrylint.config.ts
|
|
18
|
+
* import { defineLarrylintConfig } from 'larrylint'
|
|
19
|
+
*
|
|
20
|
+
* export default defineLarrylintConfig({
|
|
21
|
+
* sharedDomains: ['product'],
|
|
22
|
+
* })
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export declare function defineLarrylintConfig(config: LarrylintConfig): LarrylintConfig;
|
|
26
|
+
export declare const plugin: {
|
|
27
|
+
meta: {
|
|
28
|
+
name: string;
|
|
29
|
+
version: string;
|
|
30
|
+
};
|
|
31
|
+
rules: {
|
|
32
|
+
layers: import("eslint").Rule.RuleModule;
|
|
33
|
+
'orchestr-files': import("eslint").Rule.RuleModule;
|
|
34
|
+
definitions: import("eslint").Rule.RuleModule;
|
|
35
|
+
'button-type': import("eslint").Rule.RuleModule;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
interface LarrylintOptions {
|
|
39
|
+
/** Folder of the Laioutr app, defaults to the current working directory. */
|
|
40
|
+
cwd?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Creates the larrylint rules as ESLint flat config. The rules come without parsers,
|
|
44
|
+
* so they run on top of the project's own config, e.g. `@nuxt/eslint-config`.
|
|
45
|
+
*
|
|
46
|
+
* @param options Where to load the larrylint configuration from.
|
|
47
|
+
*
|
|
48
|
+
* @returns The flat config items.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```js
|
|
52
|
+
* // eslint.config.mjs
|
|
53
|
+
* import larrylint from 'larrylint'
|
|
54
|
+
*
|
|
55
|
+
* export default createConfigForNuxt().append(larrylint())
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
declare function larrylint(options?: LarrylintOptions): Promise<Linter.Config[]>;
|
|
59
|
+
export { type LarrylintConfig, type LarrylintOptions, larrylint as default };
|
package/dist/index.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,4 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "larrylint",
|
|
3
|
-
"
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"packageManager": "pnpm@11.5.3",
|
|
6
|
+
"description": "Opinionated structure rules for Laioutr apps, as an ESLint preset and a CLI.",
|
|
7
|
+
"author": "Frederik BuΓmann <frederik@bussmann.io>",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": "bussmann-io/larrylint",
|
|
10
|
+
"keywords": [
|
|
11
|
+
"laioutr",
|
|
12
|
+
"nuxt",
|
|
13
|
+
"eslint",
|
|
14
|
+
"eslint-plugin",
|
|
15
|
+
"architecture",
|
|
16
|
+
"lint"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./dist/index.mjs"
|
|
20
|
+
},
|
|
21
|
+
"types": "./dist/index.d.mts",
|
|
22
|
+
"bin": {
|
|
23
|
+
"larrylint": "dist/cli/index.mjs"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "obuild",
|
|
33
|
+
"lint": "eslint .",
|
|
34
|
+
"lint:fix": "eslint . --fix",
|
|
35
|
+
"prepack": "pnpm run build",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"test": "vitest run"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@typescript-eslint/parser": "^8.70.1",
|
|
41
|
+
"c12": "^3.3.4",
|
|
42
|
+
"citty": "^0.2.2",
|
|
43
|
+
"consola": "^3.4.2",
|
|
44
|
+
"eslint": "^10.11.0",
|
|
45
|
+
"magicast": "^0.5.5",
|
|
46
|
+
"nypm": "^0.6.10",
|
|
47
|
+
"pathe": "^2.0.3",
|
|
48
|
+
"pkg-types": "^2.3.3",
|
|
49
|
+
"typescript": "^6.0.3",
|
|
50
|
+
"vue-eslint-parser": "^10.4.1"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@antfu/eslint-config": "^9.5.1",
|
|
54
|
+
"@types/estree": "^1.0.9",
|
|
55
|
+
"@types/node": "^24.13.6",
|
|
56
|
+
"obuild": "^0.4.40",
|
|
57
|
+
"vitest": "^5.0.1"
|
|
58
|
+
}
|
|
4
59
|
}
|