onsystem 0.0.1 → 0.5.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.
@@ -0,0 +1,36 @@
1
+ import { a as DesignSystemModel, x as ResolvedConfig } from "./types-DJtccu3A.js";
2
+ import { MessagePort } from "node:worker_threads";
3
+ //#region src/eslint/worker.d.ts
4
+ export interface LoadRequest {
5
+ /** ESLint's working directory: where `check` would run from. */
6
+ cwd: string;
7
+ /** The file being linted, absolute. */
8
+ file: string;
9
+ /** `settings.onsystem` in the ESLint config: `--root` and `--config`, relative to `cwd`. */
10
+ root?: string | undefined;
11
+ config?: string | undefined;
12
+ /** The version of each design system the plugin already holds, by project root. */
13
+ known: Record<string, string>;
14
+ }
15
+ export type LoadResponse = {
16
+ /** Why the file is not checked, as `check .` would leave it out. */
17
+ skip: string;
18
+ } | {
19
+ root: string;
20
+ version: string;
21
+ /** Left out when the plugin already holds this version. */
22
+ data?: {
23
+ config: ResolvedConfig;
24
+ model: DesignSystemModel;
25
+ };
26
+ } | {
27
+ error: string;
28
+ };
29
+ export interface Call {
30
+ request: LoadRequest;
31
+ /** Set to 1, and notified, once the response is on `port`. */
32
+ signal: Int32Array;
33
+ port: MessagePort;
34
+ }
35
+ export declare function load(request: LoadRequest): Promise<LoadResponse>;
36
+ //#endregion
@@ -0,0 +1,88 @@
1
+ import { B as DEFAULT_TEST_FOLDERS, i as componentFiles, it as relativePath, r as buildModel, rt as matchesGlob, z as DEFAULT_TEST_EXCLUDE } from "./design-system-CnEv5sIL.js";
2
+ import { r as loadTarget } from "./workspace-DzWQ4L8R.js";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { parentPort } from "node:worker_threads";
6
+ //#region src/eslint/worker.ts
7
+ /**
8
+ * The ESLint plugin's loader, in a worker thread. ESLint rules run synchronously, and finding a
9
+ * project's design system does not (config files written in code, globs, the extraction
10
+ * cache), so the plugin asks this worker and waits for the answer (see `bridge.ts`).
11
+ */
12
+ /** How long a loaded design system is trusted before its files are looked at again. */
13
+ const FRESH_MS = 2e3;
14
+ const targets = /* @__PURE__ */ new Map();
15
+ /** The default test and story patterns: the recommended config ignores those files itself. */
16
+ const TEST_PATTERNS = /* @__PURE__ */ new Set([DEFAULT_TEST_EXCLUDE, DEFAULT_TEST_FOLDERS]);
17
+ const projects = /* @__PURE__ */ new Map();
18
+ function realPath(file) {
19
+ try {
20
+ return fs.realpathSync.native(file);
21
+ } catch {
22
+ return file;
23
+ }
24
+ }
25
+ async function targetFor({ cwd, root, config }) {
26
+ const key = JSON.stringify([
27
+ cwd,
28
+ root,
29
+ config
30
+ ]);
31
+ const cached = targets.get(key);
32
+ if (cached && Date.now() - cached.at < FRESH_MS) return cached.target;
33
+ const target = loadTarget({
34
+ cwd,
35
+ root: root === void 0 ? void 0 : path.resolve(cwd, root),
36
+ config: config === void 0 ? void 0 : path.resolve(cwd, config)
37
+ });
38
+ targets.set(key, {
39
+ at: Date.now(),
40
+ target
41
+ });
42
+ return target;
43
+ }
44
+ async function load(request) {
45
+ const target = await targetFor(request);
46
+ const config = target.workspace ? (await target.workspace.projectFor(request.file)).config : target.config;
47
+ let project = projects.get(config.root);
48
+ if (!project || Date.now() - project.at >= FRESH_MS) {
49
+ const { model, key } = await buildModel(config, { cache: true });
50
+ const own = new Set((await componentFiles(config)).map(realPath));
51
+ project = {
52
+ at: Date.now(),
53
+ version: key ?? String(Date.now()),
54
+ config,
55
+ model,
56
+ own
57
+ };
58
+ projects.set(config.root, project);
59
+ }
60
+ const relative = relativePath(config.root, request.file);
61
+ const excluded = config.exclude.find((pattern) => !TEST_PATTERNS.has(pattern) && matchesGlob(relative, pattern));
62
+ if (excluded) return { skip: `excluded by "${excluded}"` };
63
+ if (!config.includeDesignSystem && project.own.has(realPath(request.file))) return { skip: "one of the design system’s own component files" };
64
+ const { version } = project;
65
+ return {
66
+ root: config.root,
67
+ version,
68
+ ...request.known[config.root] === version ? {} : { data: {
69
+ config: project.config,
70
+ model: project.model
71
+ } }
72
+ };
73
+ }
74
+ parentPort?.on("message", ({ request, signal, port }) => {
75
+ const reply = (response) => {
76
+ port.postMessage(response);
77
+ Atomics.store(signal, 0, 1);
78
+ Atomics.notify(signal, 0);
79
+ };
80
+ load({
81
+ ...request,
82
+ file: path.resolve(request.cwd, request.file)
83
+ }).then(reply, (error) => {
84
+ reply({ error: error.message });
85
+ });
86
+ });
87
+ //#endregion
88
+ export { load };
@@ -0,0 +1,23 @@
1
+ import { Linter, Rule } from "eslint";
2
+ //#region src/eslint/index.d.ts
3
+ /** `settings.onsystem`: where the project and its config are, when finding them from ESLint's cwd is not right. */
4
+ export interface Settings {
5
+ /** The project root, relative to ESLint's working directory (`check --root`). */
6
+ root?: string;
7
+ /** The config file, relative to ESLint's working directory (`check --config`). */
8
+ config?: string;
9
+ }
10
+ declare const plugin: {
11
+ meta: {
12
+ name: string;
13
+ version: string;
14
+ };
15
+ rules: {
16
+ [k: string]: Rule.RuleModule;
17
+ };
18
+ configs: {
19
+ recommended: Linter.Config;
20
+ };
21
+ };
22
+ //#endregion
23
+ export { plugin as default };
package/dist/eslint.js ADDED
@@ -0,0 +1,172 @@
1
+ import { B as DEFAULT_TEST_FOLDERS, R as DEFAULT_SEVERITY, T as RULES, _ as VERSION, g as NAME, it as relativePath, t as DesignSystem, z as DEFAULT_TEST_EXCLUDE } from "./design-system-CnEv5sIL.js";
2
+ import path from "node:path";
3
+ import { MessageChannel, Worker, receiveMessageOnPort } from "node:worker_threads";
4
+ //#region src/eslint/bridge.ts
5
+ /**
6
+ * Calls the loader worker (`worker.ts`) synchronously: posts the request, blocks on a shared
7
+ * flag until the worker has answered, and takes the answer off the port. The worker is
8
+ * started on first use and does not keep ESLint's process alive.
9
+ */
10
+ /** Long enough for a cold extraction of a large design system. */
11
+ const TIMEOUT_MS = 12e4;
12
+ let worker;
13
+ function start() {
14
+ if (worker) return worker;
15
+ worker = new Worker(new URL("./eslint-worker.js", import.meta.url));
16
+ worker.unref();
17
+ worker.on("error", () => {
18
+ worker = void 0;
19
+ });
20
+ worker.on("exit", () => {
21
+ worker = void 0;
22
+ });
23
+ return worker;
24
+ }
25
+ function loadSync(request) {
26
+ const { port1, port2 } = new MessageChannel();
27
+ const signal = new Int32Array(new SharedArrayBuffer(4));
28
+ try {
29
+ const call = {
30
+ request,
31
+ signal,
32
+ port: port2
33
+ };
34
+ start().postMessage(call, [port2]);
35
+ if (Atomics.wait(signal, 0, 0, TIMEOUT_MS) === "timed-out") return { error: `the design system did not load within ${TIMEOUT_MS / 1e3} seconds` };
36
+ const message = receiveMessageOnPort(port1);
37
+ return message ? message.message : { error: "the loader sent no answer" };
38
+ } finally {
39
+ port1.close();
40
+ }
41
+ }
42
+ //#endregion
43
+ //#region src/eslint/index.ts
44
+ /**
45
+ * onsystem's rules as an ESLint plugin (ESLint 9 and 10, flat config):
46
+ *
47
+ * import onsystem from 'onsystem/eslint';
48
+ * export default [...yourConfig, onsystem.configs.recommended];
49
+ *
50
+ * Each file is checked as `onsystem check` checks it, against the design system of its project
51
+ * (the nearest folder with a config, a components.json or a package.json, found from ESLint's
52
+ * working directory), with the project's config: its `rules` and `overrides` turn rules off
53
+ * per file, `// onsystem-disable-*` comments suppress findings, and the files `check .` leaves
54
+ * out (the config's `exclude`, the design system's own components) are not checked. ESLint's
55
+ * config sets each rule's severity.
56
+ */
57
+ const RULES_PAGE = "https://onsystem.vercel.app/rules";
58
+ /** Design systems by project root, at the version the loader last reported. */
59
+ const designSystems = /* @__PURE__ */ new Map();
60
+ /**
61
+ * One check per file per lint pass, shared by every rule of the pass: they see the same
62
+ * SourceCode, and a new pass (another run, a fix pass, an editor's next keystroke) gets a new
63
+ * one, so the design system's freshness is asked about again. `reported` says that a file that
64
+ * could not be checked has been said so in this pass.
65
+ */
66
+ const passes = /* @__PURE__ */ new WeakMap();
67
+ function passFor(context, file, settings) {
68
+ const { sourceCode } = context;
69
+ let pass = passes.get(sourceCode);
70
+ if (!pass) {
71
+ pass = {
72
+ outcome: run(context.cwd, file, sourceCode.text, settings),
73
+ reported: false
74
+ };
75
+ passes.set(sourceCode, pass);
76
+ }
77
+ return pass;
78
+ }
79
+ function run(cwd, file, text, settings) {
80
+ const response = loadSync({
81
+ cwd,
82
+ file,
83
+ known: Object.fromEntries([...designSystems].map(([root, { version }]) => [root, version])),
84
+ root: settings.root,
85
+ config: settings.config
86
+ });
87
+ if ("error" in response) return { error: response.error };
88
+ if ("skip" in response) return { skipped: true };
89
+ let entry = designSystems.get(response.root);
90
+ if (response.data) {
91
+ const { model, config } = response.data;
92
+ entry = {
93
+ version: response.version,
94
+ ds: new DesignSystem(model, config)
95
+ };
96
+ designSystems.set(response.root, entry);
97
+ }
98
+ if (!entry) return { error: `no design system loaded for ${response.root}` };
99
+ const result = entry.ds.check(text, relativePath(entry.ds.root, file));
100
+ return result.skipped ? { skipped: true } : { result };
101
+ }
102
+ function createRule(id, description) {
103
+ return {
104
+ meta: {
105
+ type: "problem",
106
+ docs: {
107
+ description,
108
+ url: `${RULES_PAGE}#${id}`
109
+ },
110
+ fixable: "code",
111
+ schema: []
112
+ },
113
+ create(context) {
114
+ const file = path.resolve(context.cwd, context.filename);
115
+ const settings = context.settings.onsystem ?? {};
116
+ return { "Program:exit"(node) {
117
+ const pass = passFor(context, file, settings);
118
+ const { outcome } = pass;
119
+ if ("skipped" in outcome) return;
120
+ if ("error" in outcome) {
121
+ if (pass.reported) return;
122
+ pass.reported = true;
123
+ context.report({
124
+ node,
125
+ message: `onsystem could not check this file: ${outcome.error}`
126
+ });
127
+ return;
128
+ }
129
+ for (const d of outcome.result.diagnostics) {
130
+ if (d.ruleId !== id) continue;
131
+ const edits = d.fix;
132
+ context.report({
133
+ loc: {
134
+ start: {
135
+ line: d.line,
136
+ column: d.column - 1
137
+ },
138
+ end: {
139
+ line: d.endLine,
140
+ column: d.endColumn - 1
141
+ }
142
+ },
143
+ message: d.message,
144
+ ...edits?.length ? { fix: (fixer) => edits.map((e) => fixer.replaceTextRange(e.range, e.text)) } : {}
145
+ });
146
+ }
147
+ } };
148
+ }
149
+ };
150
+ }
151
+ const rules = Object.fromEntries(RULES.map((rule) => [rule.id, createRule(rule.id, rule.description)]));
152
+ const plugin = {
153
+ meta: {
154
+ name: NAME,
155
+ version: VERSION
156
+ },
157
+ rules,
158
+ configs: {}
159
+ };
160
+ /**
161
+ * Every rule at its default severity, for `.tsx` and `.jsx` files other than tests and stories,
162
+ * which `check` also leaves out unless asked (`--include-tests`).
163
+ */
164
+ plugin.configs.recommended = {
165
+ name: "onsystem/recommended",
166
+ files: ["**/*.{tsx,jsx}"],
167
+ ignores: [DEFAULT_TEST_EXCLUDE, DEFAULT_TEST_FOLDERS],
168
+ plugins: { onsystem: plugin },
169
+ rules: Object.fromEntries(RULES.map((rule) => [`onsystem/${rule.id}`, DEFAULT_SEVERITY[rule.id]]))
170
+ };
171
+ //#endregion
172
+ export { plugin as default };