@actioneer/ads-lint 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/README.md +78 -0
- package/bin/ads-lint.mjs +74 -0
- package/package.json +33 -0
- package/src/config.mjs +152 -0
- package/src/index.mjs +8 -0
- package/src/rules/imports.mjs +190 -0
- package/src/rules/literals.mjs +82 -0
- package/src/rules/motion.mjs +75 -0
- package/src/runner.mjs +110 -0
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @actioneer/ads-lint
|
|
2
|
+
|
|
3
|
+
A design linter for the **Actioneer Design System (ADS 1.0)**. It flags
|
|
4
|
+
deviations from ADS principles on changed files, so any repo that consumes ADS
|
|
5
|
+
stays on-system without a human catching every stray hex or icon import in review.
|
|
6
|
+
|
|
7
|
+
It runs in two tiers: the **TypeScript compiler API** when `typescript` is
|
|
8
|
+
resolvable (exact import analysis), degrading to a **regex parser** when it
|
|
9
|
+
isn't — so it works in a fresh repo before `npm i`.
|
|
10
|
+
|
|
11
|
+
## What it checks
|
|
12
|
+
|
|
13
|
+
| Rule | Severity | Principle |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `external-ui-import` — importing radix/mui/assistant-ui/etc. directly | error | P7 ADS-first |
|
|
16
|
+
| `deep-import` — reaching past the barrel (`@actioneer/ads/button`) | error | P7 |
|
|
17
|
+
| `barrel-default-import` / `-namespace-import` / `-aliased-import` | error | P7 |
|
|
18
|
+
| `icon-package-import` — importing `@hugeicons`/`lucide` outside the registry | error | P4 icons |
|
|
19
|
+
| `hardcoded-hex` — a raw `#rrggbb` in code | error | tokens-only |
|
|
20
|
+
| `arbitrary-color` — `bg-[#fff]` / `text-[rgb(...)]` | error | tokens-only |
|
|
21
|
+
| `arbitrary-dimension` — `w-[13px]` / `gap-[1.5rem]` | warn | tokens-only |
|
|
22
|
+
| Motion: `unbounded-transition`, `sluggish-easing`, `zero-scale-entrance`, `undersized-zoom-entrance`, `hardcoded-duration`, `layout-property-transition`, `layout-keyframe-disclosure` | error | P3 motion |
|
|
23
|
+
|
|
24
|
+
Errors fail the run (exit 1); warnings don't unless `--strict`.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
ads-lint [files...] # lint specific files (what lint-staged passes)
|
|
30
|
+
ads-lint --staged # files staged for commit (pre-commit hook)
|
|
31
|
+
ads-lint --diff origin/main # files changed vs a ref (CI on a PR)
|
|
32
|
+
ads-lint --all # everything under the configured root
|
|
33
|
+
ads-lint --strict # fail on warnings too
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Config — `ads-lint.config.mjs` at the repo root
|
|
37
|
+
|
|
38
|
+
Deep-merged over the defaults. A **consumer** repo needs almost nothing — just
|
|
39
|
+
point at the published package:
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
export default {
|
|
43
|
+
imports: { libraryPackage: "@actioneer/ads" }, // libraryRoot stays null
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The ADS library repo itself runs in **library-dev mode** (see this repo's
|
|
48
|
+
`ads-lint.config.mjs`): `libraryRoot: "src/components"` exempts the library's own
|
|
49
|
+
internals, and `libraryPackage: "@/components"` matches the local alias.
|
|
50
|
+
|
|
51
|
+
## Wiring into a consumer repo
|
|
52
|
+
|
|
53
|
+
**Pre-commit (lint-staged)** — lint only what's staged:
|
|
54
|
+
|
|
55
|
+
```jsonc
|
|
56
|
+
// package.json
|
|
57
|
+
"lint-staged": {
|
|
58
|
+
"src/**/*.{ts,tsx,css}": "ads-lint"
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**CI (GitHub Actions)** — lint only the PR diff, so legacy code doesn't block.
|
|
63
|
+
`--diff` diffs from the merge-base, so the base branch must be present — set
|
|
64
|
+
`fetch-depth: 0` (the default shallow clone omits it):
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
- uses: actions/checkout@v4
|
|
68
|
+
with: { fetch-depth: 0 }
|
|
69
|
+
- run: npx ads-lint --diff origin/${{ github.base_ref }}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Programmatic API
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import { loadConfig, resolveFiles, lintFiles } from "@actioneer/ads-lint";
|
|
76
|
+
const cfg = await loadConfig();
|
|
77
|
+
const { findings } = lintFiles(resolveFiles({ files: myFiles }, cfg, cwd), cfg, cwd);
|
|
78
|
+
```
|
package/bin/ads-lint.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ads-lint — flag deviations from the Actioneer Design System on changed files.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// ads-lint [files...] Lint the given files.
|
|
6
|
+
// ads-lint --staged Lint files staged for commit (pre-commit hook).
|
|
7
|
+
// ads-lint --diff <ref> Lint files changed vs <ref> (CI on a PR: origin/main).
|
|
8
|
+
// ads-lint --all Lint everything under the configured root.
|
|
9
|
+
// ads-lint --strict Exit non-zero on warnings too (default: errors only).
|
|
10
|
+
// ads-lint --quiet Print only the summary line.
|
|
11
|
+
//
|
|
12
|
+
// Config: ads-lint.config.mjs at the repo root (deep-merged over defaults).
|
|
13
|
+
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
import { loadConfig } from "../src/config.mjs";
|
|
16
|
+
import { resolveFiles, lintFiles, formatFindings } from "../src/runner.mjs";
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const opts = { files: [], staged: false, diff: null, all: false, strict: false, quiet: false };
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const a = argv[i];
|
|
22
|
+
if (a === "--staged") opts.staged = true;
|
|
23
|
+
else if (a === "--all") opts.all = true;
|
|
24
|
+
else if (a === "--strict") opts.strict = true;
|
|
25
|
+
else if (a === "--quiet") opts.quiet = true;
|
|
26
|
+
else if (a === "--diff") opts.diff = argv[++i];
|
|
27
|
+
else if (a === "-h" || a === "--help") opts.help = true;
|
|
28
|
+
else if (a.startsWith("-")) {
|
|
29
|
+
console.error(`ads-lint: unknown flag ${a}`);
|
|
30
|
+
process.exit(2);
|
|
31
|
+
} else opts.files.push(a);
|
|
32
|
+
}
|
|
33
|
+
return opts;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const HELP = `ads-lint — Actioneer Design System linter
|
|
37
|
+
|
|
38
|
+
ads-lint [files...] Lint the given files
|
|
39
|
+
ads-lint --staged Lint files staged for commit
|
|
40
|
+
ads-lint --diff <ref> Lint files changed vs <ref> (e.g. origin/main)
|
|
41
|
+
ads-lint --all Lint everything under the configured root
|
|
42
|
+
ads-lint --strict Fail on warnings too (default: errors only)
|
|
43
|
+
ads-lint --quiet Print only the summary line`;
|
|
44
|
+
|
|
45
|
+
async function main() {
|
|
46
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
47
|
+
if (opts.help) {
|
|
48
|
+
console.log(HELP);
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const cwd = process.cwd();
|
|
53
|
+
const cfg = await loadConfig(cwd);
|
|
54
|
+
const files = resolveFiles(opts, cfg, cwd);
|
|
55
|
+
const { findings, fileCount } = lintFiles(files, cfg, cwd);
|
|
56
|
+
|
|
57
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
58
|
+
const warns = findings.filter((f) => f.severity === "warn");
|
|
59
|
+
|
|
60
|
+
if (findings.length && !opts.quiet) {
|
|
61
|
+
console.error(formatFindings(findings));
|
|
62
|
+
console.error("");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const summary = `ads-lint: ${fileCount} file(s) · ${errors.length} error(s) · ${warns.length} warning(s)`;
|
|
66
|
+
if (errors.length || (opts.strict && warns.length)) {
|
|
67
|
+
console.error(summary);
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
console.log(summary);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
main().then((code) => process.exit(code));
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@actioneer/ads-lint",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Design linter for the Actioneer Design System (ADS 1.0). Flags deviations from ADS principles on changed files.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ads-lint": "bin/ads-lint.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./src/index.mjs",
|
|
14
|
+
"./config": "./src/config.mjs"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"bin",
|
|
18
|
+
"src",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"typescript": ">=5"
|
|
23
|
+
},
|
|
24
|
+
"peerDependenciesMeta": {
|
|
25
|
+
"typescript": { "optional": true }
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// ads-lint — configuration surface.
|
|
2
|
+
//
|
|
3
|
+
// Every rule is governed from here, never hard-coded in a rule module. A
|
|
4
|
+
// consuming repo drops an `ads-lint.config.mjs` at its root that default-exports
|
|
5
|
+
// a partial config; unspecified keys fall back to DEFAULT_CONFIG below.
|
|
6
|
+
//
|
|
7
|
+
// Two modes, one config shape:
|
|
8
|
+
// • consumer mode — libraryRoot: null, libraryPackage: "@actioneer/ads".
|
|
9
|
+
// Every file is a consumer; all import rules apply everywhere.
|
|
10
|
+
// • library-dev mode — libraryRoot: "src/components", libraryPackage:
|
|
11
|
+
// "@/components". Files inside the library author the components, so the
|
|
12
|
+
// barrel / deep-import / banned-UI rules skip them (a sibling may deep-import
|
|
13
|
+
// a sibling); the icon-registry allowlist still applies.
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
|
|
19
|
+
/** @typedef {"error" | "warn"} Severity */
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_CONFIG = {
|
|
22
|
+
// File extensions the runner will read.
|
|
23
|
+
extensions: [".ts", ".tsx", ".css"],
|
|
24
|
+
|
|
25
|
+
// Root walked by `--all` when no explicit files are passed.
|
|
26
|
+
root: "src",
|
|
27
|
+
|
|
28
|
+
// Globs skipped entirely (checked against repo-relative POSIX paths).
|
|
29
|
+
ignore: [
|
|
30
|
+
"**/node_modules/**",
|
|
31
|
+
"**/dist/**",
|
|
32
|
+
"**/*.gen.ts",
|
|
33
|
+
"**/*.d.ts",
|
|
34
|
+
],
|
|
35
|
+
|
|
36
|
+
imports: {
|
|
37
|
+
// The barrel consumers must import components from.
|
|
38
|
+
libraryPackage: "@actioneer/ads",
|
|
39
|
+
// Path prefix that IS the library. null in consumer repos (nothing is
|
|
40
|
+
// exempt); a folder in library-dev mode (its files may cross-import).
|
|
41
|
+
libraryRoot: null,
|
|
42
|
+
// Direct imports of these bypass ADS — consumers must go through the barrel.
|
|
43
|
+
bannedUiPackages: [
|
|
44
|
+
"^@assistant-ui/react(?:/|$)",
|
|
45
|
+
"^radix-ui$",
|
|
46
|
+
"^@radix-ui/",
|
|
47
|
+
"^@headlessui/",
|
|
48
|
+
"^@mui/",
|
|
49
|
+
"^antd(?:/|$)",
|
|
50
|
+
"^@chakra-ui/",
|
|
51
|
+
"^@heroui/",
|
|
52
|
+
"^@nextui-org/",
|
|
53
|
+
"^@mantine/",
|
|
54
|
+
"^@ark-ui/",
|
|
55
|
+
"^react-aria-components$",
|
|
56
|
+
"^sonner$",
|
|
57
|
+
"^cmdk$",
|
|
58
|
+
"^vaul$",
|
|
59
|
+
"^react-select$",
|
|
60
|
+
"^react-toastify$",
|
|
61
|
+
],
|
|
62
|
+
// Icon packages may only be imported from the icon registry (principle 4).
|
|
63
|
+
bannedIconPackages: ["^@hugeicons/", "^lucide-react$", "^@fortawesome/"],
|
|
64
|
+
// File(s) allowed to import icon packages directly (the ADS registry).
|
|
65
|
+
iconRegistryAllowlist: ["src/lib/icons.tsx"],
|
|
66
|
+
// Sanctioned package subpaths a consumer MAY import directly (the design
|
|
67
|
+
// system's own CSS entries) — exempt from the deep-import rule. Any asset
|
|
68
|
+
// specifier (.css/.svg/fonts/etc.) is also exempt regardless of this list.
|
|
69
|
+
sanctionedSubpaths: ["styles.css", "theme.css", "fonts.css", "package.json"],
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
motion: {
|
|
73
|
+
enabled: true,
|
|
74
|
+
// Globs where motion rules don't apply (consumer exemption seam, mirroring
|
|
75
|
+
// literals.allow) — e.g. a consumer's vendored/animation-heavy directory.
|
|
76
|
+
allow: [],
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
literals: {
|
|
80
|
+
// Bare hex colors in .ts/.tsx (e.g. "#0a0a0b") — should be a token.
|
|
81
|
+
hex: true,
|
|
82
|
+
// Tailwind arbitrary color value in a class (e.g. "bg-[#fff]").
|
|
83
|
+
arbitraryColor: true,
|
|
84
|
+
// Tailwind arbitrary dimension (e.g. "w-[13px]") — noisier; warn-level.
|
|
85
|
+
arbitraryDimension: true,
|
|
86
|
+
// Files where raw literals are sanctioned (shaders, colorful palettes,
|
|
87
|
+
// token-swatch documentation, style token definitions).
|
|
88
|
+
allow: ["src/styles/**"],
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convert a simple glob to an anchored RegExp with minimatch-ish semantics:
|
|
94
|
+
* `** /` → zero or more leading segments (?:.*\/)?
|
|
95
|
+
* `/ **` → the dir itself or anything below (?:/.*)?
|
|
96
|
+
* `**` → any run of chars, crossing / .*
|
|
97
|
+
* `*` → any run within one segment [^/]*
|
|
98
|
+
* Placeholders keep the ** forms from being mangled by the single-* pass.
|
|
99
|
+
*/
|
|
100
|
+
export function globToRegExp(glob) {
|
|
101
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\?]/g, "\\$&");
|
|
102
|
+
const body = escaped
|
|
103
|
+
.replace(/\*\*\//g, "%%L%%")
|
|
104
|
+
.replace(/\/\*\*/g, "%%T%%")
|
|
105
|
+
.replace(/\*\*/g, "%%A%%")
|
|
106
|
+
.replace(/\*/g, "[^/]*")
|
|
107
|
+
.replace(/%%L%%/g, "(?:.*/)?")
|
|
108
|
+
.replace(/%%T%%/g, "(?:/.*)?")
|
|
109
|
+
.replace(/%%A%%/g, ".*");
|
|
110
|
+
return new RegExp(`^${body}$`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True if repo-relative POSIX `file` matches any glob in `patterns`. */
|
|
114
|
+
export function matchesAny(file, patterns) {
|
|
115
|
+
return patterns.some((p) => globToRegExp(p).test(file));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function mergeConfig(base, override) {
|
|
119
|
+
if (!override) return base;
|
|
120
|
+
const out = { ...base };
|
|
121
|
+
for (const key of Object.keys(override)) {
|
|
122
|
+
const v = override[key];
|
|
123
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
124
|
+
out[key] = mergeConfig(base[key] ?? {}, v);
|
|
125
|
+
} else {
|
|
126
|
+
out[key] = v;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Load `ads-lint.config.{mjs,js}` from `cwd` (if present) and deep-merge it over
|
|
134
|
+
* the defaults. Returns the resolved config.
|
|
135
|
+
*/
|
|
136
|
+
export async function loadConfig(cwd = process.cwd()) {
|
|
137
|
+
// Walk up from cwd so the linter finds the repo-root config even when invoked
|
|
138
|
+
// from a subdirectory.
|
|
139
|
+
let dir = cwd;
|
|
140
|
+
for (;;) {
|
|
141
|
+
for (const name of ["ads-lint.config.mjs", "ads-lint.config.js"]) {
|
|
142
|
+
const candidate = path.join(dir, name);
|
|
143
|
+
if (fs.existsSync(candidate)) {
|
|
144
|
+
const mod = await import(pathToFileURL(candidate).href);
|
|
145
|
+
return mergeConfig(DEFAULT_CONFIG, mod.default ?? mod.config ?? {});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const parent = path.dirname(dir);
|
|
149
|
+
if (parent === dir) return DEFAULT_CONFIG;
|
|
150
|
+
dir = parent;
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Programmatic API — for wiring ads-lint into other tools (CI reporters, an
|
|
2
|
+
// eventual ESLint bridge). The CLI in bin/ads-lint.mjs is a thin wrapper.
|
|
3
|
+
|
|
4
|
+
export { loadConfig, DEFAULT_CONFIG } from "./config.mjs";
|
|
5
|
+
export { resolveFiles, lintFiles, formatFindings } from "./runner.mjs";
|
|
6
|
+
export { lintImports } from "./rules/imports.mjs";
|
|
7
|
+
export { lintMotion } from "./rules/motion.mjs";
|
|
8
|
+
export { lintLiterals } from "./rules/literals.mjs";
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Import-boundary rules — the ADS governance engine (principle 7 + principle 4),
|
|
2
|
+
// generalized so it runs against any repo, not just the library itself.
|
|
3
|
+
//
|
|
4
|
+
// Parsing has two tiers:
|
|
5
|
+
// • TypeScript compiler API when `typescript` is resolvable (exact, handles
|
|
6
|
+
// multi-line specifiers, type-only imports, aliasing) — the same engine as
|
|
7
|
+
// the library's own check-ads-governance.mjs.
|
|
8
|
+
// • A regex fallback when it isn't, so the tool works in a fresh repo before
|
|
9
|
+
// `npm i typescript`. Slightly less precise on exotic import forms.
|
|
10
|
+
|
|
11
|
+
import { matchesAny } from "../config.mjs";
|
|
12
|
+
|
|
13
|
+
// Load typescript if available; otherwise fall back to regex parsing.
|
|
14
|
+
let ts = null;
|
|
15
|
+
try {
|
|
16
|
+
ts = (await import("typescript")).default;
|
|
17
|
+
} catch {
|
|
18
|
+
ts = null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @returns {{line:number, spec:string, clause:{default?:string, namespace?:string, named?:Array<{name:string, alias?:string}>}}[]} */
|
|
22
|
+
function parseImportsWithTs(relPath, source) {
|
|
23
|
+
const kind = relPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
24
|
+
const sf = ts.createSourceFile(relPath, source, ts.ScriptTarget.Latest, true, kind);
|
|
25
|
+
const out = [];
|
|
26
|
+
sf.forEachChild((node) => {
|
|
27
|
+
if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) return;
|
|
28
|
+
const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
29
|
+
const clause = {};
|
|
30
|
+
const c = node.importClause;
|
|
31
|
+
if (c) {
|
|
32
|
+
if (c.name) clause.default = c.name.text;
|
|
33
|
+
if (c.namedBindings && ts.isNamespaceImport(c.namedBindings)) {
|
|
34
|
+
clause.namespace = c.namedBindings.name.text;
|
|
35
|
+
}
|
|
36
|
+
if (c.namedBindings && ts.isNamedImports(c.namedBindings)) {
|
|
37
|
+
clause.named = c.namedBindings.elements.map((el) => ({
|
|
38
|
+
name: el.propertyName ? el.propertyName.text : el.name.text,
|
|
39
|
+
alias: el.propertyName ? el.name.text : undefined,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
out.push({ line, spec: node.moduleSpecifier.text, clause });
|
|
44
|
+
});
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const IMPORT_RE = /\bimport\s+(?:type\s+)?([\s\S]*?)\s+from\s+['"]([^'"]+)['"]|\bimport\s+['"]([^'"]+)['"]/g;
|
|
49
|
+
|
|
50
|
+
// Any asset import — never a JS component deep-import, so exempt from the rule.
|
|
51
|
+
const ASSET_RE = /\.(css|scss|sass|less|svg|png|jpe?g|gif|webp|avif|woff2?|ttf|otf|eot|json)$/i;
|
|
52
|
+
|
|
53
|
+
// Blank out comments so the regex-fallback tier doesn't flag import-like text
|
|
54
|
+
// in `// import X from "@mui/..."` or block comments (the TS-AST tier is immune).
|
|
55
|
+
function stripComments(source) {
|
|
56
|
+
return source
|
|
57
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
|
|
58
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseImportsWithRegex(rawSource) {
|
|
62
|
+
const source = stripComments(rawSource);
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const m of source.matchAll(IMPORT_RE)) {
|
|
65
|
+
const spec = m[2] ?? m[3];
|
|
66
|
+
const line = source.slice(0, m.index).split("\n").length;
|
|
67
|
+
const clauseText = (m[1] ?? "").trim();
|
|
68
|
+
const clause = {};
|
|
69
|
+
if (clauseText) {
|
|
70
|
+
const nsMatch = clauseText.match(/^\*\s+as\s+([A-Za-z_$][\w$]*)/);
|
|
71
|
+
if (nsMatch) clause.namespace = nsMatch[1];
|
|
72
|
+
const braceMatch = clauseText.match(/\{([\s\S]*)\}/);
|
|
73
|
+
if (braceMatch) {
|
|
74
|
+
clause.named = braceMatch[1]
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((s) => s.trim())
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.map((s) => {
|
|
79
|
+
const parts = s.replace(/^type\s+/, "").split(/\s+as\s+/);
|
|
80
|
+
return { name: parts[0].trim(), alias: parts[1] ? parts[1].trim() : undefined };
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// A bare leading identifier (before `{` or end) is a default import.
|
|
84
|
+
const head = clauseText.replace(/\{[\s\S]*\}/, "").replace(/,\s*$/, "").trim();
|
|
85
|
+
if (head && !head.startsWith("*") && /^[A-Za-z_$][\w$]*$/.test(head)) {
|
|
86
|
+
clause.default = head;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
out.push({ line, spec, clause });
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function lintImports(relPath, source, cfg) {
|
|
95
|
+
const { imports: c } = cfg;
|
|
96
|
+
if (!/\.(ts|tsx)$/.test(relPath)) return [];
|
|
97
|
+
|
|
98
|
+
const parsed = ts ? parseImportsWithTs(relPath, source) : parseImportsWithRegex(source);
|
|
99
|
+
const findings = [];
|
|
100
|
+
|
|
101
|
+
const insideLibrary =
|
|
102
|
+
c.libraryRoot != null &&
|
|
103
|
+
(relPath === c.libraryRoot || relPath.startsWith(`${c.libraryRoot}/`));
|
|
104
|
+
const isIconRegistry = matchesAny(relPath, c.iconRegistryAllowlist ?? []);
|
|
105
|
+
const uiPatterns = (c.bannedUiPackages ?? []).map((p) => new RegExp(p));
|
|
106
|
+
const iconPatterns = (c.bannedIconPackages ?? []).map((p) => new RegExp(p));
|
|
107
|
+
const deepPrefix = `${c.libraryPackage}/`;
|
|
108
|
+
const sanctioned = new Set(c.sanctionedSubpaths ?? []);
|
|
109
|
+
|
|
110
|
+
for (const imp of parsed) {
|
|
111
|
+
const { spec, line, clause } = imp;
|
|
112
|
+
|
|
113
|
+
// Icon-package imports are banned everywhere except the registry (principle 4).
|
|
114
|
+
if (!isIconRegistry && iconPatterns.some((re) => re.test(spec))) {
|
|
115
|
+
findings.push({
|
|
116
|
+
line,
|
|
117
|
+
rule: "icon-package-import",
|
|
118
|
+
severity: "error",
|
|
119
|
+
match: spec,
|
|
120
|
+
guidance: `Icons come only from the ADS icon registry. Add the stroke icon there and import it, never from "${spec}".`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The remaining rules concern the consumer/library boundary; library
|
|
125
|
+
// internals are exempt (a sibling may cross-import a sibling).
|
|
126
|
+
if (insideLibrary) continue;
|
|
127
|
+
|
|
128
|
+
if (spec.startsWith(deepPrefix)) {
|
|
129
|
+
const subpath = spec.slice(deepPrefix.length);
|
|
130
|
+
// Sanctioned CSS entries and any asset import are legitimate, not a
|
|
131
|
+
// barrel bypass — only JS component deep-imports are flagged.
|
|
132
|
+
if (!sanctioned.has(subpath) && !ASSET_RE.test(spec)) {
|
|
133
|
+
findings.push({
|
|
134
|
+
line,
|
|
135
|
+
rule: "deep-import",
|
|
136
|
+
severity: "error",
|
|
137
|
+
match: spec,
|
|
138
|
+
guidance: `Deep import bypasses the ADS barrel; import the exact symbol from "${c.libraryPackage}".`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (uiPatterns.some((re) => re.test(spec)) || /^https?:\/\//.test(spec)) {
|
|
144
|
+
findings.push({
|
|
145
|
+
line,
|
|
146
|
+
rule: "external-ui-import",
|
|
147
|
+
severity: "error",
|
|
148
|
+
match: spec,
|
|
149
|
+
guidance: `External UI import bypasses ADS. Use a component from "${c.libraryPackage}" (or extend ADS first).`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (spec !== c.libraryPackage) continue;
|
|
154
|
+
|
|
155
|
+
if (clause.default) {
|
|
156
|
+
findings.push({
|
|
157
|
+
line,
|
|
158
|
+
rule: "barrel-default-import",
|
|
159
|
+
severity: "error",
|
|
160
|
+
match: clause.default,
|
|
161
|
+
guidance: `Default imports from "${c.libraryPackage}" are forbidden; use exact named exports.`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (clause.namespace) {
|
|
165
|
+
findings.push({
|
|
166
|
+
line,
|
|
167
|
+
rule: "barrel-namespace-import",
|
|
168
|
+
severity: "error",
|
|
169
|
+
match: `* as ${clause.namespace}`,
|
|
170
|
+
guidance: `Namespace imports from "${c.libraryPackage}" are forbidden; use exact named exports.`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
for (const el of clause.named ?? []) {
|
|
174
|
+
if (el.alias && el.alias !== el.name) {
|
|
175
|
+
findings.push({
|
|
176
|
+
line,
|
|
177
|
+
rule: "barrel-aliased-import",
|
|
178
|
+
severity: "error",
|
|
179
|
+
match: `${el.name} as ${el.alias}`,
|
|
180
|
+
guidance: `ADS symbol ${el.name} is aliased; use the exact exported name.`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return findings;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Which parser tier is active — surfaced by the CLI banner. */
|
|
190
|
+
export const parserTier = ts ? "typescript" : "regex";
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Hardcoded-literal rules — the core ADS invariant: "No hardcoded
|
|
2
|
+
// color/dimension literals — every value flows from a token."
|
|
3
|
+
//
|
|
4
|
+
// • bare hex colors in .ts/.tsx → use a color token (error)
|
|
5
|
+
// • Tailwind arbitrary color bg-[#fff] → use a token utility (error)
|
|
6
|
+
// • Tailwind arbitrary dimension w-[13px]→ prefer a scale/token value (warn)
|
|
7
|
+
//
|
|
8
|
+
// Files where raw values are sanctioned (token definitions, shaders, colorful
|
|
9
|
+
// palettes, token-swatch docs) are exempt via `literals.allow` in config.
|
|
10
|
+
|
|
11
|
+
import { matchesAny } from "../config.mjs";
|
|
12
|
+
|
|
13
|
+
// Bare hex color in code: #RGB, #RGBA, #RRGGBB, #RRGGBBAA.
|
|
14
|
+
const HEX = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/g;
|
|
15
|
+
|
|
16
|
+
// Contexts where a `#xxxxxx` token is an id/selector/fragment, not a color.
|
|
17
|
+
const NON_COLOR_CTX =
|
|
18
|
+
/(?:querySelector(?:All)?|getElementById|getElementsBy\w+|\.matches|\.closest|location\.hash|URLSearchParams|new URL)\s*\([^)]*$/;
|
|
19
|
+
|
|
20
|
+
// Tailwind arbitrary color value, e.g. bg-[#fff], text-[rgb(0_0_0)], [color:#111].
|
|
21
|
+
const ARB_COLOR =
|
|
22
|
+
/-\[(?:#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab)\([^\]]*\))\]/g;
|
|
23
|
+
|
|
24
|
+
// Tailwind arbitrary absolute dimension, e.g. w-[13px], gap-[1.5rem], top-[2em].
|
|
25
|
+
// %, vh, vw and calc() are left alone — they're usually genuinely one-off.
|
|
26
|
+
const ARB_DIM = /-\[-?[0-9.]+(?:px|rem|em)\]/g;
|
|
27
|
+
|
|
28
|
+
function lineOf(source, index) {
|
|
29
|
+
return source.slice(0, index).split("\n").length;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function lintLiterals(relPath, source, cfg) {
|
|
33
|
+
const c = cfg.literals ?? {};
|
|
34
|
+
if (matchesAny(relPath, c.allow ?? [])) return [];
|
|
35
|
+
|
|
36
|
+
const findings = [];
|
|
37
|
+
const isCode = /\.(ts|tsx)$/.test(relPath);
|
|
38
|
+
|
|
39
|
+
if (c.hex !== false && isCode) {
|
|
40
|
+
for (const m of source.matchAll(HEX)) {
|
|
41
|
+
const prev = source[m.index - 1];
|
|
42
|
+
// arbitrary-color owns the bracketed form; "." / "#" mean a private field
|
|
43
|
+
// or id-ish token, not a color.
|
|
44
|
+
if (prev === "[" || prev === "." || prev === "#") continue;
|
|
45
|
+
// DOM selector / URL-fragment argument, not a color value.
|
|
46
|
+
if (NON_COLOR_CTX.test(source.slice(Math.max(0, m.index - 48), m.index))) continue;
|
|
47
|
+
findings.push({
|
|
48
|
+
line: lineOf(source, m.index),
|
|
49
|
+
rule: "hardcoded-hex",
|
|
50
|
+
severity: "error",
|
|
51
|
+
match: m[0],
|
|
52
|
+
guidance: "Use an ADS color token, not a raw hex value.",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (c.arbitraryColor !== false && (isCode || relPath.endsWith(".css"))) {
|
|
58
|
+
for (const m of source.matchAll(ARB_COLOR)) {
|
|
59
|
+
findings.push({
|
|
60
|
+
line: lineOf(source, m.index),
|
|
61
|
+
rule: "arbitrary-color",
|
|
62
|
+
severity: "error",
|
|
63
|
+
match: m[0],
|
|
64
|
+
guidance: "Use a token color utility (bg-*/text-*), not an arbitrary value.",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (c.arbitraryDimension !== false && isCode) {
|
|
70
|
+
for (const m of source.matchAll(ARB_DIM)) {
|
|
71
|
+
findings.push({
|
|
72
|
+
line: lineOf(source, m.index),
|
|
73
|
+
rule: "arbitrary-dimension",
|
|
74
|
+
severity: "warn",
|
|
75
|
+
match: m[0],
|
|
76
|
+
guidance: "Prefer a spacing/size token or the Tailwind scale over a raw dimension.",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return findings;
|
|
82
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Motion rules (principle 3) — regex over .css/.ts/.tsx. Banned easing,
|
|
2
|
+
// unbounded transitions, undersized entrances, hardcoded/at-or-over-300ms
|
|
3
|
+
// durations, layout-property motion.
|
|
4
|
+
//
|
|
5
|
+
// This RULES table is the SINGLE SOURCE OF TRUTH for the motion policy — the
|
|
6
|
+
// repo's scripts/check-motion.mjs imports it too, so the two never drift.
|
|
7
|
+
|
|
8
|
+
import { matchesAny } from "../config.mjs";
|
|
9
|
+
|
|
10
|
+
export const RULES = [
|
|
11
|
+
{
|
|
12
|
+
name: "unbounded-transition",
|
|
13
|
+
severity: "error",
|
|
14
|
+
pattern: /\btransition-all\b/g,
|
|
15
|
+
guidance: "List the animated properties explicitly (transform/opacity).",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "sluggish-easing",
|
|
19
|
+
severity: "error",
|
|
20
|
+
pattern: /\bease-in(?!-out)\b/g,
|
|
21
|
+
guidance: "Use the ADS ease-out token for UI entrances and exits.",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "zero-scale-entrance",
|
|
25
|
+
severity: "error",
|
|
26
|
+
pattern: /\bscale-0\b|\bscale\s*:\s*0(?=\s*[,}])/g,
|
|
27
|
+
guidance: "Start at scale 0.9–0.97 with opacity instead of scale(0).",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "undersized-zoom-entrance",
|
|
31
|
+
severity: "error",
|
|
32
|
+
pattern: /(?<!cursor-)\bzoom-(?:in|out)\b(?!-(?:9\d|100)\b)/g,
|
|
33
|
+
guidance: "Use zoom-in/out-95 so the element retains physical presence.",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "hardcoded-duration",
|
|
37
|
+
severity: "error",
|
|
38
|
+
pattern: /\bduration-\d+\b|\[animation-duration:\s*\d/g,
|
|
39
|
+
guidance: "Use an ADS duration token (--duration-fast / --duration-normal).",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "layout-property-transition",
|
|
43
|
+
severity: "error",
|
|
44
|
+
pattern:
|
|
45
|
+
/transition-\[[^\]]*(?:width|height|grid-template|margin|padding|top|right|bottom|left)[^\]]*\]/g,
|
|
46
|
+
guidance: "Animate transform/opacity, or drop motion for frequent disclosure.",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "layout-keyframe-disclosure",
|
|
50
|
+
severity: "error",
|
|
51
|
+
pattern: /animate-(?:accordion|collapsible)-(?:down|up)/g,
|
|
52
|
+
guidance: "Do not animate height for high-frequency disclosure controls.",
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export function lintMotion(relPath, source, cfg) {
|
|
57
|
+
if (cfg.motion?.enabled === false) return [];
|
|
58
|
+
if (matchesAny(relPath, cfg.motion?.allow ?? [])) return [];
|
|
59
|
+
if (!/\.(ts|tsx|css)$/.test(relPath)) return [];
|
|
60
|
+
|
|
61
|
+
const findings = [];
|
|
62
|
+
for (const rule of RULES) {
|
|
63
|
+
rule.pattern.lastIndex = 0;
|
|
64
|
+
for (const m of source.matchAll(rule.pattern)) {
|
|
65
|
+
findings.push({
|
|
66
|
+
line: source.slice(0, m.index).split("\n").length,
|
|
67
|
+
rule: rule.name,
|
|
68
|
+
severity: rule.severity,
|
|
69
|
+
match: m[0],
|
|
70
|
+
guidance: rule.guidance,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return findings;
|
|
75
|
+
}
|
package/src/runner.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Runner — resolve the file set, dispatch each file to every rule, format.
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { matchesAny } from "./config.mjs";
|
|
7
|
+
import { lintImports } from "./rules/imports.mjs";
|
|
8
|
+
import { lintMotion } from "./rules/motion.mjs";
|
|
9
|
+
import { lintLiterals } from "./rules/literals.mjs";
|
|
10
|
+
|
|
11
|
+
const RULE_FNS = [lintImports, lintMotion, lintLiterals];
|
|
12
|
+
|
|
13
|
+
function toPosix(p) {
|
|
14
|
+
return p.split(path.sep).join("/");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Recursively collect files with a matching extension under `dir`. */
|
|
18
|
+
function walk(dir, extensions) {
|
|
19
|
+
if (!fs.existsSync(dir)) return [];
|
|
20
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
21
|
+
const full = path.join(dir, entry.name);
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
if (entry.name === "node_modules" || entry.name === ".git") return [];
|
|
24
|
+
return walk(full, extensions);
|
|
25
|
+
}
|
|
26
|
+
return extensions.includes(path.extname(entry.name)) ? [full] : [];
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function git(args, cwd) {
|
|
31
|
+
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Files changed vs a git ref (committed diff) or the index (--staged). */
|
|
35
|
+
function gitFiles({ diff, staged }, cwd) {
|
|
36
|
+
// git prints repo-root-relative paths, so resolve against the toplevel, not
|
|
37
|
+
// cwd (the linter may be invoked from a subdirectory).
|
|
38
|
+
let root = cwd;
|
|
39
|
+
try {
|
|
40
|
+
root = git(["rev-parse", "--show-toplevel"], cwd) || cwd;
|
|
41
|
+
} catch {
|
|
42
|
+
/* not a git repo — fall back to cwd */
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const args = ["diff", "--name-only", "--diff-filter=ACMR"];
|
|
46
|
+
if (staged) args.push("--cached");
|
|
47
|
+
// `<ref>...HEAD` diffs from the MERGE-BASE (what a PR actually adds), not the
|
|
48
|
+
// ref tip. CI note: needs full history — set actions/checkout fetch-depth: 0.
|
|
49
|
+
if (diff) args.push(`${diff}...HEAD`);
|
|
50
|
+
|
|
51
|
+
let out;
|
|
52
|
+
try {
|
|
53
|
+
out = git(args, cwd);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
const ref = diff ? `"${diff}"` : "the index";
|
|
56
|
+
throw new Error(
|
|
57
|
+
`ads-lint: git diff against ${ref} failed — is ${diff ? `${diff} present (shallow clone? set fetch-depth: 0)` : "this a git repo"}?\n${err.message}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return out.split("\n").filter(Boolean).map((f) => path.resolve(root, f));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the list of absolute file paths to lint from CLI options.
|
|
65
|
+
* Precedence: explicit files > --staged/--diff > --all/root walk.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveFiles(opts, cfg, cwd) {
|
|
68
|
+
let files;
|
|
69
|
+
if (opts.files.length > 0) {
|
|
70
|
+
files = opts.files.map((f) => path.resolve(cwd, f));
|
|
71
|
+
} else if (opts.staged || opts.diff) {
|
|
72
|
+
files = gitFiles(opts, cwd);
|
|
73
|
+
} else {
|
|
74
|
+
files = walk(path.resolve(cwd, cfg.root), cfg.extensions);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return files.filter((abs) => {
|
|
78
|
+
if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return false;
|
|
79
|
+
if (!cfg.extensions.includes(path.extname(abs))) return false;
|
|
80
|
+
const rel = toPosix(path.relative(cwd, abs));
|
|
81
|
+
return !matchesAny(rel, cfg.ignore ?? []);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Lint one resolved file set. Returns { findings, fileCount }. */
|
|
86
|
+
export function lintFiles(absFiles, cfg, cwd) {
|
|
87
|
+
const findings = [];
|
|
88
|
+
for (const abs of absFiles) {
|
|
89
|
+
const rel = toPosix(path.relative(cwd, abs));
|
|
90
|
+
const source = fs.readFileSync(abs, "utf8");
|
|
91
|
+
for (const fn of RULE_FNS) {
|
|
92
|
+
for (const f of fn(rel, source, cfg)) {
|
|
93
|
+
findings.push({ file: rel, ...f });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
findings.sort(
|
|
98
|
+
(a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.rule.localeCompare(b.rule)
|
|
99
|
+
);
|
|
100
|
+
return { findings, fileCount: absFiles.length };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function formatFindings(findings) {
|
|
104
|
+
return findings
|
|
105
|
+
.map(
|
|
106
|
+
(f) =>
|
|
107
|
+
`${f.file}:${f.line} [${f.severity}] ${f.rule}: ${f.match} — ${f.guidance}`
|
|
108
|
+
)
|
|
109
|
+
.join("\n");
|
|
110
|
+
}
|