@wix/origami-lint 1.34.34 → 1.34.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/bi/stats.js +28 -14
- package/dist/lint.js +11 -22
- package/dist/page-model/read-structure.js +5 -5
- package/dist/page-model/utils.js +5 -4
- package/dist/project-rules-runner/run-project-rules.js +3 -3
- package/dist/rules/site-app/breakpoints.js +13 -15
- package/dist/rules/site-app/no-free-code.js +1 -2
- package/dist/rules/site-app/page-structure.js +10 -10
- package/dist/rules/site-app/theme-font-ids.js +1 -1
- package/dist/rules-adapter/{oxlint → eslint}/adapt-rule.js +8 -7
- package/dist/rules-adapter/{oxlint/oxlint-plugin.js → eslint/eslint-plugin.js} +3 -3
- package/dist/rules-adapter/eslint/run-rules.js +60 -0
- package/dist/rules-adapter/index.js +2 -2
- package/dist/rules-adapter/utils.js +1 -1
- package/dist/rules-api/rules-api.js +2 -2
- package/dist/shared/log.js +4 -0
- package/package.json +8 -6
- package/dist/bi/read-stats.js +0 -34
- package/dist/rules-adapter/oxlint/origami-rules.config.js +0 -12
- package/dist/rules-adapter/oxlint/run-rules.js +0 -28
- /package/dist/rules-adapter/{oxlint → eslint}/callbacks.js +0 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Offline lint for an exported Origami site. Each run lints only the pages whose files changed since the last passing
|
|
4
4
|
run (every page when `theme.css` changed), and runs every rule on every component of those pages.
|
|
5
|
-
The rules run as plain
|
|
5
|
+
The rules run as plain ESLint rules, in the lint's own process.
|
|
6
6
|
|
|
7
7
|
Published to the Wix registry (public npm later). How the lint works, its parts and how to write a rule:
|
|
8
8
|
[lint/README.md](lint/README.md).
|
|
@@ -35,8 +35,8 @@ pnpm --filter @wix/origami-lint run lint
|
|
|
35
35
|
```
|
|
36
36
|
lint/ the source (lint.ts is the entry point and the origami-lint bin)
|
|
37
37
|
dist/ lint/ built to JavaScript: what the package publishes (Node won't run .ts inside node_modules)
|
|
38
|
-
test/ rules on in-memory pages, CSS, the pages to lint, the
|
|
38
|
+
test/ rules on in-memory pages, CSS, the pages to lint, the ESLint adapter; history, the site root and a whole run on temporary repositories
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
The
|
|
42
|
-
`
|
|
41
|
+
The ESLint config is built in `lint/rules-adapter/eslint/run-rules.ts` and passed to ESLint's Node API, so a site's
|
|
42
|
+
own `eslint.config.js` is never loaded. `pnpm run lint` (this package's own source) still runs oxlint.
|
package/dist/bi/stats.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
// The oxlint process collects only ids, counts and timings. No BI sender runs in the plugin.
|
|
2
|
-
import { writeFileSync } from 'node:fs';
|
|
3
1
|
import { GLOBAL_COMPONENTS_FOLDER } from "../shared/site-files-tree-mapping.js";
|
|
4
2
|
import { readStructure } from "../page-model/read-structure.js";
|
|
5
3
|
const rules = [];
|
|
6
4
|
export const models = [];
|
|
7
5
|
const pages = new Map();
|
|
8
|
-
let
|
|
6
|
+
let collecting = false;
|
|
7
|
+
/** BI is on: from now on, the rules and the page models collect their stats. */
|
|
8
|
+
export function collectStats() {
|
|
9
|
+
collecting = true;
|
|
10
|
+
}
|
|
11
|
+
export const isCollectingStats = () => collecting;
|
|
9
12
|
/** Resolve the same root component id the page model reads; never use its path as a BI id. */
|
|
10
13
|
export function pageId(filename, text, program) {
|
|
11
14
|
let page = pages.get(filename);
|
|
@@ -19,19 +22,9 @@ export function pageId(filename, text, program) {
|
|
|
19
22
|
}
|
|
20
23
|
/** Called once per rule and file by the adapter. Passing rules retain their zero count. */
|
|
21
24
|
export function startRuleStats(rule, checks, filename, text, program) {
|
|
22
|
-
|
|
23
|
-
if (!destination)
|
|
25
|
+
if (!collecting)
|
|
24
26
|
return;
|
|
25
27
|
try {
|
|
26
|
-
if (!registered) {
|
|
27
|
-
registered = true;
|
|
28
|
-
process.once('exit', () => {
|
|
29
|
-
try {
|
|
30
|
-
writeFileSync(destination, JSON.stringify({ rules, models }), { mode: 0o600 });
|
|
31
|
-
}
|
|
32
|
-
catch { /* Stats must never fail lint. */ }
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
28
|
const kind = filename.startsWith(`${GLOBAL_COMPONENTS_FOLDER}/`) ? 'component' : 'page';
|
|
36
29
|
const page = pageId(filename, text, program);
|
|
37
30
|
const stats = { rule, checks: Object.fromEntries(checks.map(check => [check, 0])), kind, page, violations: 0, duration_ms: 0 };
|
|
@@ -42,3 +35,24 @@ export function startRuleStats(rule, checks, filename, text, program) {
|
|
|
42
35
|
return undefined;
|
|
43
36
|
}
|
|
44
37
|
}
|
|
38
|
+
/** The collected stats as rows: a page model's build, then each rule on each file and its checks that found problems. Only ids, counts and timings, never a message or a path. */
|
|
39
|
+
export function recordStats(run) {
|
|
40
|
+
try {
|
|
41
|
+
for (const model of models) {
|
|
42
|
+
run.recordRow('step', 'build_page_model', { item_type: model.kind, item_id: model.page,
|
|
43
|
+
step_details: { components: model.components, css_files: model.css_files } }, {
|
|
44
|
+
success: !model.error_name, failure: model.error_name ? { error_name: model.error_name } : undefined, duration_ms: model.duration_ms,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
for (const statsRow of rules) {
|
|
48
|
+
const row = run.recordRow('lint_rule', statsRow.rule, { item_type: statsRow.kind, item_id: statsRow.page, step_details: { violations: statsRow.violations } }, {
|
|
49
|
+
success: statsRow.violations === 0, duration_ms: statsRow.duration_ms,
|
|
50
|
+
});
|
|
51
|
+
for (const [check, violations] of Object.entries(statsRow.checks)) {
|
|
52
|
+
if (violations > 0)
|
|
53
|
+
run.recordRow('lint_rule', statsRow.rule, { item_type: statsRow.kind, item_id: statsRow.page, step_details: { check, violations } }, { success: false, duration_ms: 0 }, row);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch { /* Stats must never fail lint. */ }
|
|
58
|
+
}
|
package/dist/lint.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createWorkspaceRun, sendAndExit } from '@wix/origami-bi';
|
|
3
|
-
import { readFileSync
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import path from 'node:path';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
6
4
|
// Origami lint: runs the project rules on the site's own files, then lints the pages whose files changed since the last
|
|
7
5
|
// passing run. See lint/README.md.
|
|
8
6
|
import { getChangedFiles, markPassing } from "./history/index.js";
|
|
@@ -12,7 +10,7 @@ import { runProjectRules } from "./project-rules-runner/index.js";
|
|
|
12
10
|
import { checkStartedAtSiteRoot, LINT_PACKAGE_FILE } from "./shared/paths.js";
|
|
13
11
|
import { GLOBAL_COMPONENTS_FOLDER, THEME_FILE } from "./shared/site-files-tree-mapping.js";
|
|
14
12
|
import { track, trackSync } from "./bi/names.js";
|
|
15
|
-
import {
|
|
13
|
+
import { collectStats, recordStats } from "./bi/stats.js";
|
|
16
14
|
const run = createWorkspaceRun({ command: 'yarn lint', toolVersion: JSON.parse(readFileSync(LINT_PACKAGE_FILE, 'utf8')).version });
|
|
17
15
|
await run.trackCommand(async () => {
|
|
18
16
|
run.commandRow.update({ step_details: { is_full: process.argv.includes('--full'), has_base: Boolean(process.env.ORIGAMI_LINT_BASE) } });
|
|
@@ -35,24 +33,15 @@ await run.trackCommand(async () => {
|
|
|
35
33
|
row?.update({ step_details: { pages: pages.length - globals, global_components: globals, theme_changed: changedFiles.changed === null || changedFiles.changed.includes(THEME_FILE) } });
|
|
36
34
|
return pages;
|
|
37
35
|
});
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
finally {
|
|
49
|
-
if (statsFile) {
|
|
50
|
-
try {
|
|
51
|
-
rmSync(statsFile, { force: true });
|
|
52
|
-
}
|
|
53
|
-
catch { /* Stats must never fail lint. */ }
|
|
54
|
-
}
|
|
55
|
-
}
|
|
36
|
+
if (run.enabled)
|
|
37
|
+
collectStats();
|
|
38
|
+
const pagesPassed = await track('step', 'run_rules', { items_total: pagesToLint.length }, async (row) => {
|
|
39
|
+
const result = await runRules(pagesToLint);
|
|
40
|
+
if (run.enabled)
|
|
41
|
+
recordStats(run);
|
|
42
|
+
row?.update({ step_details: { outcome: result.outcome } });
|
|
43
|
+
row?.end(result.passed);
|
|
44
|
+
return result.passed;
|
|
56
45
|
});
|
|
57
46
|
const passed = projectPassed && pagesPassed;
|
|
58
47
|
if (passed)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// structure.jsx (or .tsx) → components. Every JSX element inside Structure is a component.
|
|
2
2
|
// Only what the lint tool can't give a rule: each component's key and parentKey, to connect its CSS. The rest is in the tool's syntax tree.
|
|
3
|
-
import {
|
|
3
|
+
import { parse } from '@typescript-eslint/parser';
|
|
4
4
|
import { findDeclaration, isIdAttribute, getLiteralClassNames, getLiteralId, getNestingContext, createSourceReader, walk, emptyComponentCss } from "./utils.js";
|
|
5
|
-
/** program: the same text's
|
|
6
|
-
export function readStructure(jsxFile, text, program =
|
|
5
|
+
/** program: the same text's syntax tree, when the lint tool already has it (ESLint's, from the same parser); otherwise parsed here. */
|
|
6
|
+
export function readStructure(jsxFile, text, program = parse(text, { filePath: jsxFile, ecmaFeatures: { jsx: true }, range: true })) {
|
|
7
7
|
const source = createSourceReader(text);
|
|
8
8
|
const elements = findElements(program);
|
|
9
9
|
return { components: toComponents(elements, assignKeys(elements, source), source) };
|
|
@@ -19,7 +19,7 @@ function findElements(program) {
|
|
|
19
19
|
if (node.type !== 'JSXElement')
|
|
20
20
|
return;
|
|
21
21
|
const { parent, slot } = getNestingContext(ancestors);
|
|
22
|
-
const siblings = `${parent?.
|
|
22
|
+
const siblings = `${parent?.range[0]}|${slot}`;
|
|
23
23
|
const index = siblingCounts.get(siblings) ?? 0;
|
|
24
24
|
siblingCounts.set(siblings, index + 1);
|
|
25
25
|
elements.push({ node: node, parent, slot, index });
|
|
@@ -46,7 +46,7 @@ function toComponents(elements, keys, source) {
|
|
|
46
46
|
const key = keys.get(node);
|
|
47
47
|
components[key] = {
|
|
48
48
|
key,
|
|
49
|
-
sourceStart: node.
|
|
49
|
+
sourceStart: node.range[0],
|
|
50
50
|
id: getLiteralId(node),
|
|
51
51
|
classNames: getLiteralClassNames(node),
|
|
52
52
|
parentKey: parent ? keys.get(parent) : null,
|
package/dist/page-model/utils.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CSS_KINDS } from "./model.js";
|
|
2
2
|
// ── syntax tree ──
|
|
3
|
-
|
|
3
|
+
const isAstNode = (value) => typeof value?.type === 'string';
|
|
4
|
+
/** Calls visit(node, ancestors) for every node. Skips `parent`, which ESLint's syntax tree has (the parser's alone doesn't). */
|
|
4
5
|
export function walk(node, ancestors, visit) {
|
|
5
6
|
visit(node, ancestors);
|
|
6
7
|
ancestors.push(node);
|
|
@@ -8,7 +9,7 @@ export function walk(node, ancestors, visit) {
|
|
|
8
9
|
if (key === 'parent')
|
|
9
10
|
continue;
|
|
10
11
|
for (const child of Array.isArray(value) ? value : [value])
|
|
11
|
-
if (
|
|
12
|
+
if (isAstNode(child))
|
|
12
13
|
walk(child, ancestors, visit);
|
|
13
14
|
}
|
|
14
15
|
ancestors.pop();
|
|
@@ -68,8 +69,8 @@ export function createSourceReader(text) {
|
|
|
68
69
|
return { line: line + 1, column: offset - lineStarts[line] };
|
|
69
70
|
};
|
|
70
71
|
return {
|
|
71
|
-
code: (node) => text.slice(node.
|
|
72
|
-
loc: (node) => ({ start: position(node.
|
|
72
|
+
code: (node) => text.slice(...node.range).replace(/\s+/g, ' ').trim(),
|
|
73
|
+
loc: (node) => ({ start: position(node.range[0]), end: position(node.range[1]) }),
|
|
73
74
|
};
|
|
74
75
|
}
|
|
75
76
|
// ── CSS ──
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { projectRuleRegistry } from "../rules/project/index.js";
|
|
4
|
-
import { log } from "../shared/log.js";
|
|
4
|
+
import { formatProblemLine, log } from "../shared/log.js";
|
|
5
5
|
import { ROOT } from "../shared/paths.js";
|
|
6
6
|
import { createProjectModel } from "../project-model/index.js";
|
|
7
7
|
/** true when no error rule reported a problem, on the site of these files. Logs how many rules run, and whether they passed. */
|
|
@@ -38,9 +38,9 @@ export function runProjectRules(files, { rules = projectRuleRegistry, readFile =
|
|
|
38
38
|
log(passed ? 'project rules passed' : 'project rules failed');
|
|
39
39
|
return passed;
|
|
40
40
|
}
|
|
41
|
-
/** `file:line:1: error origami(name): message`, the shape
|
|
41
|
+
/** `file:line:1: error origami(name): message`, the shape the page rules' problems print in. */
|
|
42
42
|
export function formatProblem({ name, severity = 'error' }, { file, line = 1, message }) {
|
|
43
|
-
return
|
|
43
|
+
return formatProblemLine({ file, line, column: 1, severity: severity === 'error' ? 'error' : 'warning', rule: name, message });
|
|
44
44
|
}
|
|
45
45
|
function readFromSite(file) {
|
|
46
46
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describeAtRule, reportCssProblem } from "../../rules-api/index.js";
|
|
2
2
|
import { defineRule } from "./define-rule.js";
|
|
3
|
-
const CHECKS = ['malformed_custom_media', 'duplicate_breakpoint', 'unknown_breakpoint_name', 'changed_breakpoint_range', '
|
|
3
|
+
const CHECKS = ['malformed_custom_media', 'duplicate_breakpoint', 'unknown_breakpoint_name', 'changed_breakpoint_range', 'unknown_breakpoint_range'];
|
|
4
4
|
/** The converter's shape for a declaration's name: a dashed identifier. */
|
|
5
5
|
const DASHED_NAME = /^--[A-Za-z0-9_-]+$/;
|
|
6
6
|
export default defineRule({
|
|
@@ -63,27 +63,27 @@ function keepsSiteRange(file, declaration, site, report) {
|
|
|
63
63
|
reportCssProblem(report, file.name, declaration.line, 'changed_breakpoint_range', `\`@custom-media ${declaration.name} ${declaration.query}\` changes the site's range for ${declaration.name}, which is \`${exported}\`. Restore it; a breakpoint's width is changed in the editor, not in CSS.`);
|
|
64
64
|
return false;
|
|
65
65
|
}
|
|
66
|
-
/** Reports a `@media (--name)`
|
|
66
|
+
/** Reports a `@media (--name)` whose name isn't one of the site's breakpoints (CONV-breakpoints.ts:150): the import reads the name from the site's
|
|
67
|
+
* breakpoints, so the file needs no `@custom-media` of its own. Nothing without a theme to compare with. A name the file declares is reported on its
|
|
68
|
+
* declaration (checkDeclarations), so the use gets no second line. */
|
|
67
69
|
function checkNamedUses(file, site, report) {
|
|
70
|
+
if (!site.hasTheme)
|
|
71
|
+
return;
|
|
68
72
|
const declared = new Set(file.declared.flatMap(({ name }) => [name, `--${name}`]));
|
|
69
73
|
for (const { name, line } of file.used) {
|
|
70
|
-
if (!name || declared.has(name))
|
|
74
|
+
if (!name || declared.has(name) || getSiteRange(name, site) !== undefined)
|
|
71
75
|
continue;
|
|
72
|
-
|
|
73
|
-
const fix = range
|
|
74
|
-
? `add \`@custom-media ${name} ${range};\` at the top of ${file.name}, or write the range itself, \`@media ${range}\``
|
|
75
|
-
: `add \`@custom-media ${name} (<its range>);\` at the top of ${file.name} (the range the site gives it), or write the range itself`;
|
|
76
|
-
reportCssProblem(report, file.name, line, 'undeclared_breakpoint', `\`@media (${name})\` uses a name the file doesn't declare: ${fix}.`);
|
|
76
|
+
reportCssProblem(report, file.name, line, 'unknown_breakpoint_name', `\`@media (${name})\` names a breakpoint the site doesn't have. Use one of the theme's breakpoints: ${describe(site.breakpoints)}.`);
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
-
/** Reports each `@media` of a file whose range is none of the site's (P4-R01, CONV-breakpoints.ts:75); nothing without a theme to compare with. A name is read
|
|
79
|
+
/** Reports each `@media` of a file whose range is none of the site's (P4-R01, CONV-breakpoints.ts:75); nothing without a theme to compare with. A name is read as the site's range for it; an unknown name is checkNamedUses'. A component's own breakpoint (inside `@scope`) is compared once unmapped.json is read. */
|
|
80
80
|
function checkRanges(file, site, report) {
|
|
81
81
|
if (!site.hasTheme)
|
|
82
82
|
return;
|
|
83
83
|
for (const media of file.used) {
|
|
84
84
|
if (media.scope !== null)
|
|
85
85
|
continue;
|
|
86
|
-
const query = resolve(media,
|
|
86
|
+
const query = resolve(media, site);
|
|
87
87
|
if (query === null || site.breakpoints.some((breakpoint) => canonical(breakpoint.query) === canonical(query)))
|
|
88
88
|
continue;
|
|
89
89
|
reportCssProblem(report, file.name, media.line, 'unknown_breakpoint_range', `\`${describeAtRule({ name: 'media', params: media.query })}\` is a breakpoint the site doesn't have. Write one of the theme's breakpoints: ${describe(site.breakpoints)}. The import refuses a value for a breakpoint the site doesn't have.`);
|
|
@@ -112,13 +112,11 @@ function getMedia(file, isPage, rulesApi) {
|
|
|
112
112
|
const hasShape = ({ name, query }) => DASHED_NAME.test(name) && query !== '';
|
|
113
113
|
/** The site's range for a name; undefined when the theme has no well-formed declaration of it. */
|
|
114
114
|
const getSiteRange = (name, site) => site.breakpoints.find((breakpoint) => breakpoint.name === name)?.query;
|
|
115
|
-
/** The range to compare: the @media's own; for `(--name)`, the range
|
|
116
|
-
function resolve(media,
|
|
115
|
+
/** The range to compare: the @media's own; for `(--name)`, the site's range for that name, as the import reads it (an unknown name is checkNamedUses'); null when there is nothing to compare. */
|
|
116
|
+
function resolve(media, site) {
|
|
117
117
|
if (!media.name)
|
|
118
118
|
return media.query;
|
|
119
|
-
|
|
120
|
-
const exported = getSiteRange(media.name, site);
|
|
121
|
-
return declaration && exported !== undefined && exported.trim() === declaration.query.trim() ? declaration.query : null;
|
|
119
|
+
return getSiteRange(media.name, site) ?? null;
|
|
122
120
|
}
|
|
123
121
|
/** How the import reads a range: `(max-width:1023px)` and `(max-width: 1023px)` are the same; anything else, a different case or other spaces included, is another query. */
|
|
124
122
|
const canonical = (query) => query.trim().replace(/:\s+/g, ':');
|
|
@@ -59,7 +59,7 @@ function isAllowedStatement({ node, rulesApi, report, context }) {
|
|
|
59
59
|
}
|
|
60
60
|
/** Skips a file with no root element (page-structure reports it) and a body that is TypeScript (hasNoTypeScript reports it). */
|
|
61
61
|
function returnsElement({ node, rulesApi, report }) {
|
|
62
|
-
const body =
|
|
62
|
+
const { body } = node;
|
|
63
63
|
if (body.type === 'JSXElement' || rulesApi.isTypeScript(body) || !rulesApi.getRoot())
|
|
64
64
|
return;
|
|
65
65
|
report({ check: 'invalid_structure_declaration', loc: node.body.loc, message: STRUCTURE_SHAPE });
|
|
@@ -107,7 +107,6 @@ function isStructureArrow(node) {
|
|
|
107
107
|
const statement = node.parent.parent?.parent;
|
|
108
108
|
return statement?.type === 'ExportNamedDeclaration' && isAtTop(statement) && structureArrowOf(statement) === node;
|
|
109
109
|
}
|
|
110
|
-
const unwrap = (node) => (node.type === 'ParenthesizedExpression' ? node.expression : node);
|
|
111
110
|
function shortCode(code) {
|
|
112
111
|
const [line] = code.trim().split('\n');
|
|
113
112
|
return line.length > 40 ? `${line.slice(0, 40)}…` : line;
|
|
@@ -148,7 +148,7 @@ function isOutsideInternalContainers({ node, rulesApi, report }) {
|
|
|
148
148
|
return;
|
|
149
149
|
report({ check: 'landmark_inside_container', loc: getLoc(node, rulesApi), message: `The ${kind} ${describeElement(node, rulesApi)} is inside the internal container ${describeElement(container, rulesApi)}. Move it out of every internal container: the header and footer sit directly in the page or the master page.` });
|
|
150
150
|
}
|
|
151
|
-
/** Reports a wrapper that doesn't hold exactly one element: none, two, text, or a {…}
|
|
151
|
+
/** Reports a wrapper that doesn't hold exactly one element: none, two, text, or a {…} beside it; a comment is fine (CONV-parseJsx.ts:1028). */
|
|
152
152
|
function holdsOneElement({ node, rulesApi, report }) {
|
|
153
153
|
if (holdsOne(node))
|
|
154
154
|
return;
|
|
@@ -161,13 +161,13 @@ function checkNoRemoveInRemove({ node, rulesApi, report }) {
|
|
|
161
161
|
return;
|
|
162
162
|
report({ check: 'nested_remove', loc: node.openingElement.name.loc, message: `<${rulesApi.getTagName(node)}> is inside another <Remove>. <Remove> wraps the component itself: merge the two conditions into one <Remove>.` });
|
|
163
163
|
}
|
|
164
|
-
/** Reports text, a {…}
|
|
164
|
+
/** Reports text, a {…}, a fragment or a spread between a component's tags (CONV-parseJsx.ts:1668). A comment is fine, as whitespace is: the import
|
|
165
|
+
* skips both (it reads a `@name` comment as the component's name). The root steps report the rest in <Page> and its sections. */
|
|
165
166
|
function hasOnlyElements({ node, rulesApi, report }) {
|
|
166
|
-
|
|
167
|
+
if (isRootTreeHolder(node, rulesApi))
|
|
168
|
+
return;
|
|
167
169
|
for (const child of node.children) {
|
|
168
|
-
if (child.type === 'JSXElement' || (child
|
|
169
|
-
continue;
|
|
170
|
-
if (commentsOnly && !isComment(child))
|
|
170
|
+
if (child.type === 'JSXElement' || isFiller(child))
|
|
171
171
|
continue;
|
|
172
172
|
report({ check: 'non_element_child', loc: child.type === 'JSXText' ? getTextLoc(child) : child.loc, message: `${describeElement(node, rulesApi)} has text or {…} as a child. Put the value in an attribute: only elements go between the tags.` });
|
|
173
173
|
}
|
|
@@ -283,13 +283,13 @@ function isRootTreeHolder(holder, rulesApi) {
|
|
|
283
283
|
}
|
|
284
284
|
/** Whether the element is one of the holder's children (through wrappers), not something passed in one of its attributes. */
|
|
285
285
|
const isStructuralChild = (holder, element, rulesApi) => getStructuralChildren(holder, rulesApi).includes(element);
|
|
286
|
-
/** A wrapper holds exactly one element: non-blank text, a {…}
|
|
286
|
+
/** A wrapper holds exactly one element: non-blank text, a {…} and a fragment count as what it holds; whitespace and comments don't. */
|
|
287
287
|
const holdsOne = (wrapper) => {
|
|
288
|
-
const inside = wrapper.children.filter((child) => child
|
|
288
|
+
const inside = wrapper.children.filter((child) => !isFiller(child));
|
|
289
289
|
return inside.length === 1 && inside[0].type === 'JSXElement';
|
|
290
290
|
};
|
|
291
|
-
/**
|
|
292
|
-
const
|
|
291
|
+
/** What the import skips between tags: whitespace, and a JSX comment (an expression container with nothing in it). */
|
|
292
|
+
const isFiller = (child) => (child.type === 'JSXText' && child.value.trim() === '') || (child.type === 'JSXExpressionContainer' && child.expression.type === 'JSXEmptyExpression');
|
|
293
293
|
/** `backgroundLayer={[<Background … />]}`: an array literal whose every entry is an element. */
|
|
294
294
|
function isArrayOfElements(attribute) {
|
|
295
295
|
const { value } = attribute;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// from the Wix font catalog in @wix/origami-fonts: no quotes, commas or fallbacks, the same case. The editor reads that
|
|
3
3
|
// family as one Wix font id. The import keeps any family as written (an unknown id, a quoted one, a list, another case),
|
|
4
4
|
// so this is a warning. A legacy font is in the catalog, and the import takes it. A `var(…)` token is left alone.
|
|
5
|
-
// theme.css isn't a file
|
|
5
|
+
// theme.css isn't a file ESLint lints, so the rule runs on each linted page (a theme change lints every page) and
|
|
6
6
|
// marks the page's start; the message names the token's line in theme.css.
|
|
7
7
|
import { CATALOG_FONTS, readFontShorthand, reportCssProblem } from "../../rules-api/index.js";
|
|
8
8
|
import { defineRule } from "./define-rule.js";
|
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
// An origami rule as an
|
|
1
|
+
// An origami rule as an ESLint rule: its callbacks are ESLint's, so the adapter only adds what every callback gets.
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { startRuleStats } from "../../bi/stats.js";
|
|
4
4
|
import { getOrCreateRulesApi } from "../../rules-api/index.js";
|
|
5
5
|
import { createReport, createToolCallbacks } from "../utils.js";
|
|
6
|
-
export function
|
|
6
|
+
export function toEslintRule(rule) {
|
|
7
7
|
return {
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
// ESLint reports by message id: one message, the rule's own text as its data.
|
|
9
|
+
meta: { type: 'problem', schema: [], messages: { problem: '{{message}}' } },
|
|
10
|
+
defaultOptions: [],
|
|
11
|
+
// ESLint calls this once per rule per file. The file's rules API is shared by every rule (getOrCreateRulesApi caches it).
|
|
10
12
|
create: (context) => {
|
|
11
13
|
const stats = startRuleStats(rule.name, rule.checks, path.relative(context.cwd, context.filename), context.sourceCode.text, context.sourceCode.ast);
|
|
12
|
-
const report = createReport((problem) => context.report(problem), stats);
|
|
14
|
+
const report = createReport((problem) => context.report({ messageId: 'problem', data: { message: problem.message }, loc: problem.loc }), stats);
|
|
13
15
|
return createToolCallbacks(rule, { rulesApi: () => getOrCreateRulesApi(toLintedFile(context)), report, context, stats });
|
|
14
16
|
},
|
|
15
17
|
};
|
|
16
18
|
}
|
|
17
|
-
/**
|
|
19
|
+
/** ESLint already parsed the file: its syntax tree is reused instead of parsing it again. */
|
|
18
20
|
function toLintedFile({ cwd, filename, sourceCode }) {
|
|
19
|
-
// oxlint's tree is oxc-parser's (oxlint runs on oxc), plus `parent`, `range` and `loc`. Their types are declared apart.
|
|
20
21
|
return { cwd, filename, text: sourceCode.text, program: sourceCode.ast };
|
|
21
22
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// The plugin
|
|
1
|
+
// The plugin run-rules.ts gives ESLint: every rule as an ESLint rule.
|
|
2
2
|
import { ruleRegistry } from "../../rules/index.js";
|
|
3
|
-
import {
|
|
3
|
+
import { toEslintRule } from "./adapt-rule.js";
|
|
4
4
|
export default {
|
|
5
5
|
meta: { name: 'origami' },
|
|
6
|
-
rules: Object.fromEntries(ruleRegistry.map((rule) => [rule.name,
|
|
6
|
+
rules: Object.fromEntries(ruleRegistry.map((rule) => [rule.name, toEslintRule(rule)])),
|
|
7
7
|
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Runs the rules with ESLint, in this process, on the pages' structure files: the plugin (eslint-plugin.ts) on a config
|
|
2
|
+
// built here, never the site's own ESLint config.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import * as parser from '@typescript-eslint/parser';
|
|
5
|
+
import { ESLint } from 'eslint';
|
|
6
|
+
import { ruleRegistry } from "../../rules/index.js";
|
|
7
|
+
import { formatProblemLine, log } from "../../shared/log.js";
|
|
8
|
+
import { ROOT } from "../../shared/paths.js";
|
|
9
|
+
import plugin from "./eslint-plugin.js";
|
|
10
|
+
/** Each rule on at its own severity. Warnings don't fail the lint. */
|
|
11
|
+
export const ruleSeverities = (rules) => Object.fromEntries(rules.map(({ name, severity = 'error' }) => [`origami/${name}`, severity]));
|
|
12
|
+
/** How ESLint reads a structure file: typescript-eslint's parser reads .jsx and .tsx alike; its tree is the one the rules' types are. */
|
|
13
|
+
export const STRUCTURE_FILES = {
|
|
14
|
+
files: ['**/*.jsx', '**/*.tsx'],
|
|
15
|
+
languageOptions: { parser, parserOptions: { ecmaFeatures: { jsx: true } } },
|
|
16
|
+
// An `eslint-disable` comment the site has for its own tools isn't a problem of the page.
|
|
17
|
+
linterOptions: { reportUnusedDisableDirectives: 'off' },
|
|
18
|
+
};
|
|
19
|
+
/** Every rule, on the structure files. */
|
|
20
|
+
export const CONFIG = [{
|
|
21
|
+
...STRUCTURE_FILES,
|
|
22
|
+
plugins: { origami: plugin },
|
|
23
|
+
rules: ruleSeverities(ruleRegistry),
|
|
24
|
+
}];
|
|
25
|
+
/** jsxFiles: the pages' structure files, from the site root. Each problem printed as one line; passed when there was nothing to lint, or no error. */
|
|
26
|
+
export async function runRules(jsxFiles) {
|
|
27
|
+
const result = await runEslint(jsxFiles);
|
|
28
|
+
log(result.passed ? 'rules passed' : 'rules failed');
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
async function runEslint(files) {
|
|
32
|
+
if (files.length === 0)
|
|
33
|
+
return { passed: true, outcome: 'no_files' };
|
|
34
|
+
let results;
|
|
35
|
+
try {
|
|
36
|
+
results = await lintStructureFiles(files);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// A rule that couldn't finish fails the run, like a project rule.
|
|
40
|
+
console.log(formatProblemLine({ file: '.', line: 1, column: 1, severity: 'error', message: `the page rules couldn't finish, so this run can't pass: ${error instanceof Error ? error.message : String(error)}` }));
|
|
41
|
+
return { passed: false, outcome: 'plugin_crash' };
|
|
42
|
+
}
|
|
43
|
+
for (const line of results.flatMap((result) => toProblemLines(result)))
|
|
44
|
+
console.log(line);
|
|
45
|
+
const passed = results.every((result) => result.errorCount === 0);
|
|
46
|
+
return { passed, outcome: passed ? 'passed' : 'violations' };
|
|
47
|
+
}
|
|
48
|
+
/** Every rule on these structure files (paths from cwd, the site root): ESLint's results, nothing printed. */
|
|
49
|
+
export function lintStructureFiles(files, cwd = ROOT) {
|
|
50
|
+
// ignore: false, so a page in a folder ESLint skips by default is linted too: these are the files to lint.
|
|
51
|
+
return new ESLint({ cwd, overrideConfigFile: true, overrideConfig: CONFIG, ignore: false }).lintFiles(files);
|
|
52
|
+
}
|
|
53
|
+
/** A file's problems as lines, from the site root. A parse error has no rule, and only its own text. */
|
|
54
|
+
export function toProblemLines({ filePath, messages }, cwd = ROOT) {
|
|
55
|
+
const file = path.relative(cwd, filePath).split(path.sep).join('/');
|
|
56
|
+
return messages.map(({ line, column, severity, ruleId, message }) => formatProblemLine({
|
|
57
|
+
file, line, column, severity: severity === 2 ? 'error' : 'warning',
|
|
58
|
+
rule: ruleId?.replace(/^origami\//, ''), message: ruleId ? message : message.replace(/^Parsing error: /, ''),
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
// Part 5 — rules adapter: runs the rules inside a lint tool (
|
|
2
|
-
export { runRules } from "./
|
|
1
|
+
// Part 5 — rules adapter: runs the rules inside a lint tool (ESLint today). Everything that knows ESLint is in eslint/.
|
|
2
|
+
export { runRules } from "./eslint/run-rules.js";
|
|
3
3
|
export { createReport, createToolCallbacks } from "./utils.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** The report a rule gets on a file: a violation found from both sides (parent and child) is reported once.
|
|
1
|
+
/** The report a rule gets on a file: a violation found from both sides (parent and child) is reported once. ESLint doesn't do that. */
|
|
2
2
|
export function createReport(send, stats) {
|
|
3
3
|
const reported = new Set();
|
|
4
4
|
return (problem) => {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// One per file, shared by every rule on it. Only building its page reads from disk, once per file.
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { GLOBAL_COMPONENTS_FOLDER } from "../shared/site-files-tree-mapping.js";
|
|
6
|
-
import { models, pageId } from "../bi/stats.js";
|
|
6
|
+
import { isCollectingStats, models, pageId } from "../bi/stats.js";
|
|
7
7
|
import { readPage } from "../page-model/index.js";
|
|
8
8
|
import { getAttributeElements as getHeldElements, getAttributeValue, getChildren, getId, getImport, getImportKind, getImportedName, getPropNames, getSlot, getTagName, getType, hasDataAttributes, isTypeScript } from "../shared/utils.js";
|
|
9
9
|
import { CONTENT_TARGET, readBreakpointName, readCustomMedia, readThemeFontTokens, readThemeTokens } from "../shared/css.js";
|
|
@@ -78,7 +78,7 @@ export function getOrCreateRulesApi(file) {
|
|
|
78
78
|
throw error;
|
|
79
79
|
}
|
|
80
80
|
finally {
|
|
81
|
-
if (
|
|
81
|
+
if (isCollectingStats()) {
|
|
82
82
|
try {
|
|
83
83
|
const kind = getRelativeJsxPath(file).startsWith(`${GLOBAL_COMPONENTS_FOLDER}/`) ? 'component' : 'page';
|
|
84
84
|
models.push({ kind, page: pageId(getRelativeJsxPath(file), file.text, file.program), duration_ms: performance.now() - started,
|
package/dist/shared/log.js
CHANGED
|
@@ -2,3 +2,7 @@
|
|
|
2
2
|
export function log(message) {
|
|
3
3
|
console.log(`origami-lint [${Math.round(performance.now())} ms]: ${message}`);
|
|
4
4
|
}
|
|
5
|
+
/** `file:line:column: error origami(rule): message`, or `file:line:column: error: message` without a rule: one line per problem, the shape agents read. */
|
|
6
|
+
export function formatProblemLine({ file, line, column, severity, rule, message }) {
|
|
7
|
+
return `${file}:${line}:${column}: ${severity}${rule ? ` origami(${rule})` : ''}: ${message}`;
|
|
8
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/origami-lint",
|
|
3
|
-
"version": "1.34.
|
|
3
|
+
"version": "1.34.36",
|
|
4
4
|
"description": "Offline lint for an exported Origami site: lints the pages that changed since the last passing run",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"!**/*.tsbuildinfo"
|
|
13
13
|
],
|
|
14
14
|
"engines": {
|
|
15
|
-
"node": "^20.19.0 ||
|
|
15
|
+
"node": "^20.19.0 || ^22.13.0 || >=24"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "rm -rf dist && tsc -p . && tsc -p tsconfig.build.json",
|
|
@@ -21,15 +21,17 @@
|
|
|
21
21
|
"test": "node --test 'test/*.test.ts'"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
+
"@typescript-eslint/parser": "^8.71.2",
|
|
24
25
|
"@wix/origami-fonts": "1.0.4",
|
|
25
|
-
"
|
|
26
|
-
"oxlint": "^1.39.0",
|
|
26
|
+
"eslint": "^10.12.0",
|
|
27
27
|
"postcss": "^8.5.3",
|
|
28
|
+
"typescript": "^5.8.3 || ~6.0.3",
|
|
28
29
|
"@wix/origami-bi": "0.14.0"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
32
|
"@types/node": "^22.0.0",
|
|
32
|
-
"typescript": "^
|
|
33
|
+
"@typescript-eslint/utils": "^8.71.2",
|
|
34
|
+
"oxlint": "^1.39.0"
|
|
33
35
|
},
|
|
34
36
|
"publishUnscoped": false,
|
|
35
37
|
"publishConfig": {
|
|
@@ -52,5 +54,5 @@
|
|
|
52
54
|
]
|
|
53
55
|
}
|
|
54
56
|
},
|
|
55
|
-
"falconPackageHash": "
|
|
57
|
+
"falconPackageHash": "5ed1f78b0c887661ab15c42ac1bb33758bdc0b4f1afd67d94b64c989"
|
|
56
58
|
}
|
package/dist/bi/read-stats.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { ruleRegistry } from "../rules/index.js";
|
|
3
|
-
/** Best-effort transport, explicitly projecting the plugin's data so messages and paths cannot ride along. */
|
|
4
|
-
export function readStats(file, run) {
|
|
5
|
-
try {
|
|
6
|
-
const stats = JSON.parse(readFileSync(file, 'utf8'));
|
|
7
|
-
const validPage = (page) => page === undefined || typeof page === 'string' && /^[\w-]+$/.test(page);
|
|
8
|
-
const validKind = (kind) => kind === 'page' || kind === 'component';
|
|
9
|
-
const count = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
10
|
-
if (!Array.isArray(stats.rules) || !Array.isArray(stats.models)
|
|
11
|
-
|| !stats.rules.every(s => s && ruleRegistry.some(rule => rule.name === s.rule && s.checks && typeof s.checks === 'object' && !Array.isArray(s.checks)
|
|
12
|
-
&& Object.keys(s.checks).length === rule.checks.length && rule.checks.every(check => Object.hasOwn(s.checks, check))
|
|
13
|
-
&& Object.values(s.checks).every(value => Number.isSafeInteger(value) && value >= 0)
|
|
14
|
-
&& Object.values(s.checks).reduce((total, value) => total + value, 0) === s.violations) && validPage(s.page) && validKind(s.kind) && Number.isSafeInteger(s.violations) && s.violations >= 0 && count(s.duration_ms))
|
|
15
|
-
|| !stats.models.every(s => s && validPage(s.page) && validKind(s.kind) && count(s.duration_ms) && count(s.components) && count(s.css_files) && (s.error_name === undefined || s.error_name === 'css_syntax_error' || s.error_name === 'unknown_error')))
|
|
16
|
-
return;
|
|
17
|
-
for (const model of stats.models) {
|
|
18
|
-
run.recordRow('step', 'build_page_model', { item_type: model.kind, item_id: model.page,
|
|
19
|
-
step_details: { components: model.components, css_files: model.css_files } }, {
|
|
20
|
-
success: !model.error_name, failure: model.error_name ? { error_name: model.error_name } : undefined, duration_ms: model.duration_ms,
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
for (const statsRow of stats.rules) {
|
|
24
|
-
const row = run.recordRow('lint_rule', statsRow.rule, { item_type: statsRow.kind, item_id: statsRow.page, step_details: { violations: statsRow.violations } }, {
|
|
25
|
-
success: statsRow.violations === 0, duration_ms: statsRow.duration_ms,
|
|
26
|
-
});
|
|
27
|
-
for (const [check, violations] of Object.entries(statsRow.checks)) {
|
|
28
|
-
if (violations > 0)
|
|
29
|
-
run.recordRow('lint_rule', statsRow.rule, { item_type: statsRow.kind, item_id: statsRow.page, step_details: { check, violations } }, { success: false, duration_ms: 0 }, row);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
catch { /* Missing, truncated or malformed stats never affect lint. */ }
|
|
34
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
// Passed to oxlint with -c by run-rules.ts. Not named oxlint.config.ts, so `oxlint .` in the origami repo does not load it as a nested config.
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { defineConfig } from 'oxlint';
|
|
4
|
-
import { ruleRegistry } from "../../rules/index.js";
|
|
5
|
-
/** Each rule on at its own severity. oxlint exits 0 when it finds only warnings, so warnings don't fail the lint. */
|
|
6
|
-
export const ruleSeverities = (rules) => Object.fromEntries(rules.map(({ name, severity = 'error' }) => [`origami/${name}`, severity]));
|
|
7
|
-
export default defineConfig({
|
|
8
|
-
// .ts in the origami repo, .js once built: the build rewrites imports, not this path.
|
|
9
|
-
jsPlugins: [path.join(import.meta.dirname, `oxlint-plugin${path.extname(import.meta.filename)}`)],
|
|
10
|
-
categories: { correctness: 'off' },
|
|
11
|
-
rules: ruleSeverities(ruleRegistry),
|
|
12
|
-
});
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
// Runs the rules with oxlint on the pages' structure files. Its config loads the rules (through oxlint-plugin.ts).
|
|
2
|
-
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import { createRequire } from 'node:module';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { log } from "../../shared/log.js";
|
|
6
|
-
import { ROOT } from "../../shared/paths.js";
|
|
7
|
-
// oxlint is the lint's own dependency: found from here, wherever the package manager installed it.
|
|
8
|
-
const OXLINT_BIN = path.join(path.dirname(createRequire(import.meta.url).resolve('oxlint/package.json')), 'bin/oxlint');
|
|
9
|
-
// .ts in the origami repo, .js once built.
|
|
10
|
-
const OXLINT_CONFIG = path.join(import.meta.dirname, `origami-rules.config${path.extname(import.meta.filename)}`);
|
|
11
|
-
/** jsxFiles: the pages' structure files, from the site root. true when there was nothing to lint, or oxlint found no errors. */
|
|
12
|
-
export function runRules(jsxFiles, statsFile) {
|
|
13
|
-
const result = runOxlint(jsxFiles, statsFile);
|
|
14
|
-
log(result.passed ? 'rules passed' : 'rules failed');
|
|
15
|
-
return result;
|
|
16
|
-
}
|
|
17
|
-
function runOxlint(files, statsFile) {
|
|
18
|
-
if (files.length === 0) {
|
|
19
|
-
return { passed: true, exitCode: null, signal: null, outcome: 'no_files' };
|
|
20
|
-
}
|
|
21
|
-
const env = { ...process.env };
|
|
22
|
-
delete env.ORIGAMI_LINT_STATS;
|
|
23
|
-
if (statsFile)
|
|
24
|
-
env.ORIGAMI_LINT_STATS = statsFile;
|
|
25
|
-
const result = spawnSync(process.execPath, [OXLINT_BIN, '-c', OXLINT_CONFIG, ...files], { cwd: ROOT, stdio: 'inherit', env });
|
|
26
|
-
return { passed: result.status === 0, exitCode: result.status, signal: result.signal,
|
|
27
|
-
outcome: result.status === 0 ? 'passed' : result.status === 1 ? 'violations' : 'plugin_crash' };
|
|
28
|
-
}
|
|
File without changes
|