oxlint-plugin-boundary-interfaces 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam Siekierski
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,66 @@
1
+ # oxlint-plugin-boundary-interfaces
2
+
3
+ Enforce architectural boundaries with a per-folder public interface.
4
+
5
+ **A folder containing an `interface.json` is a bounded context.** Code outside that folder may
6
+ only import the paths the manifest lists; everything else in the folder is internal. A folder with
7
+ no `interface.json` is unrestricted — so you adopt one context at a time.
8
+
9
+ Generic: no hardcoded directory layout or alias. Import specifiers are resolved with the project's
10
+ `tsconfig.json` `compilerOptions.paths`, plus normal relative resolution.
11
+
12
+ ## The manifest — `interface.json`
13
+
14
+ ```json
15
+ { "public": ["components/Button.tsx", "values/*", "lib/**"] }
16
+ ```
17
+
18
+ Paths are relative to the folder, without extension.
19
+
20
+ - exact — `hooks/useGraphConfig`
21
+ - `/*` — one level (`values/source`, not `values/nested/x`)
22
+ - `/**` — any depth
23
+
24
+ ## Rule — `boundary-interfaces/boundary-interfaces`
25
+
26
+ When a file outside a context imports a path not in that context's `public` list, it reports a
27
+ diagnostic. Same-context imports are never checked. Nested contexts work: the nearest ancestor
28
+ `interface.json` defines the boundary.
29
+
30
+ ### oxlint
31
+
32
+ ```jsonc
33
+ // .oxlintrc.json
34
+ {
35
+ "jsPlugins": [
36
+ { "name": "boundary-interfaces", "specifier": "oxlint-plugin-boundary-interfaces" }
37
+ ],
38
+ "rules": {
39
+ "boundary-interfaces/boundary-interfaces": "warn"
40
+ }
41
+ }
42
+ ```
43
+
44
+ ### Options
45
+
46
+ ```jsonc
47
+ ["warn", {
48
+ "manifestName": "interface.json", // default
49
+ "exemptImporters": ["/__tests__/", "/__fixtures__/"] // importer-path substrings exempt from the rule
50
+ }]
51
+ ```
52
+
53
+ ## Dead-public check — `boundary-interfaces-check`
54
+
55
+ Per-file linting can't tell whether a public path is used *anywhere else*. This CLI scans the repo
56
+ and warns for any `public` entry that no other context imports (dead public surface). Exits non-zero
57
+ on findings — wire it into CI / pre-push.
58
+
59
+ ```bash
60
+ boundary-interfaces-check # scans ./src
61
+ boundary-interfaces-check packages # scans a given root
62
+ ```
63
+
64
+ ## License
65
+
66
+ MIT
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "oxlint-plugin-boundary-interfaces",
3
+ "version": "0.1.0",
4
+ "description": "oxlint/ESLint plugin: a folder with an interface.json is a bounded context; outsiders may only import the paths it lists.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "bin": {
11
+ "boundary-interfaces-check": "src/check.js"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test"
19
+ },
20
+ "keywords": [
21
+ "oxlint",
22
+ "eslint",
23
+ "boundaries",
24
+ "architecture",
25
+ "bounded-context",
26
+ "domain"
27
+ ],
28
+ "author": "Adam Siekierski",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/AdamSiekierski/oxlint-plugin-boundary-interfaces.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/AdamSiekierski/oxlint-plugin-boundary-interfaces/issues"
36
+ },
37
+ "homepage": "https://github.com/AdamSiekierski/oxlint-plugin-boundary-interfaces#readme",
38
+ "engines": {
39
+ "node": ">=18"
40
+ }
41
+ }
package/src/check.js ADDED
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import {
5
+ findContext,
6
+ getAliases,
7
+ isInside,
8
+ loadJson,
9
+ resolveSpecifier,
10
+ stripExt,
11
+ } from './lib.js';
12
+
13
+ // Repo scan: warn for public interface entries that no *other* context imports.
14
+ // Per-file linting can't see repo-wide usage, so this lives outside the oxlint rule.
15
+
16
+ const projectRoot = process.cwd();
17
+ const scanRoot = process.argv[2] ? path.resolve(process.argv[2]) : path.join(projectRoot, 'src');
18
+ const manifestName = 'interface.json';
19
+ const aliases = getAliases(projectRoot);
20
+
21
+ const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']);
22
+ // ponytail: regex import scan; misses computed/dynamic specifiers. Fine for static ESM imports.
23
+ const IMPORT_RE =
24
+ /(?:import|export)[^'"]*?from\s*['"]([^'"]+)['"]|import\(\s*['"]([^'"]+)['"]\s*\)/g;
25
+
26
+ function walk(dir, onFile) {
27
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
28
+ if (e.name === 'node_modules' || e.name === 'dist' || e.name.startsWith('.')) continue;
29
+ const full = path.join(dir, e.name);
30
+ if (e.isDirectory()) walk(full, onFile);
31
+ else onFile(full);
32
+ }
33
+ }
34
+
35
+ const usedByContext = new Map(); // contextRoot -> Set of context-relative paths (no ext)
36
+ const manifests = []; // absolute interface.json paths
37
+
38
+ walk(scanRoot, (file) => {
39
+ if (path.basename(file) === manifestName) {
40
+ manifests.push(file);
41
+ return;
42
+ }
43
+ if (!SOURCE_EXTS.has(path.extname(file))) return;
44
+
45
+ const importerDir = path.dirname(file);
46
+ const src = readFileSync(file, 'utf8');
47
+ for (const m of src.matchAll(IMPORT_RE)) {
48
+ const spec = m[1] || m[2];
49
+ const target = resolveSpecifier(spec, importerDir, projectRoot, aliases);
50
+ if (!target) continue;
51
+ const ctx = findContext(target, projectRoot, manifestName);
52
+ if (!ctx || isInside(file, ctx.root)) continue; // external, ungoverned, or same-context
53
+ const rel = stripExt(path.relative(ctx.root, target)).split(path.sep).join('/');
54
+ if (!usedByContext.has(ctx.root)) usedByContext.set(ctx.root, new Set());
55
+ usedByContext.get(ctx.root).add(rel);
56
+ }
57
+ });
58
+
59
+ let problems = 0;
60
+ for (const manifest of manifests) {
61
+ const root = path.dirname(manifest);
62
+ const publicList = loadJson(manifest)?.public ?? [];
63
+ const used = usedByContext.get(root) ?? new Set();
64
+ const usedArr = [...used];
65
+
66
+ for (const entry of publicList) {
67
+ const clean = entry.replace(/\/(\*\*?|)\s*$/, '');
68
+ const isGlob = entry.endsWith('/*') || entry.endsWith('/**');
69
+ const ok = isGlob
70
+ ? usedArr.some((u) => u === clean || u.startsWith(clean + '/'))
71
+ : used.has(stripExt(clean));
72
+ if (!ok) {
73
+ problems++;
74
+ console.warn(
75
+ `${path.relative(projectRoot, manifest)}: public entry '${entry}' is not imported by any other context.`,
76
+ );
77
+ }
78
+ }
79
+ }
80
+
81
+ if (problems === 0) console.log('boundary-interfaces: all public entries are used.');
82
+ process.exit(problems > 0 ? 1 : 0);
package/src/index.js ADDED
@@ -0,0 +1,82 @@
1
+ import path from 'node:path';
2
+ import {
3
+ findContext,
4
+ getAliases,
5
+ isInside,
6
+ matchesPublic,
7
+ resolveSpecifier,
8
+ stripExt,
9
+ } from './lib.js';
10
+
11
+ const DEFAULT_EXEMPT = ['/__tests__/', '/__fixtures__/'];
12
+
13
+ const rule = {
14
+ meta: {
15
+ docs: {
16
+ description:
17
+ 'Disallow importing a bounded context (a folder with interface.json) from outside, unless the path is listed in its public interface.',
18
+ },
19
+ schema: [
20
+ {
21
+ type: 'object',
22
+ properties: {
23
+ manifestName: { type: 'string' },
24
+ exemptImporters: { type: 'array', items: { type: 'string' } },
25
+ },
26
+ additionalProperties: false,
27
+ },
28
+ ],
29
+ },
30
+ create(context) {
31
+ const opts = context.options?.[0] ?? {};
32
+ const manifestName = opts.manifestName ?? 'interface.json';
33
+ const exempt = opts.exemptImporters ?? DEFAULT_EXEMPT;
34
+ const projectRoot = context.cwd;
35
+ const importer = context.physicalFilename || context.filename;
36
+ const importerNorm = importer.split(path.sep).join('/');
37
+
38
+ if (exempt.some((s) => importerNorm.includes(s))) return {};
39
+
40
+ const aliases = getAliases(projectRoot);
41
+ const importerDir = path.dirname(importer);
42
+
43
+ function check(node, source) {
44
+ if (!node || typeof source !== 'string') return;
45
+ const target = resolveSpecifier(source, importerDir, projectRoot, aliases);
46
+ if (!target) return;
47
+ const ctx = findContext(target, projectRoot, manifestName);
48
+ if (!ctx) return; // not a governed context — gradual rollout
49
+ if (isInside(importer, ctx.root)) return; // same context
50
+
51
+ const rel = path.relative(ctx.root, target);
52
+ if (matchesPublic(rel, ctx.publicList)) return;
53
+
54
+ context.report({
55
+ node,
56
+ message: `'${source}' reaches into bounded context '${path.basename(ctx.root)}', but '${stripExt(rel).split(path.sep).join('/')}' is not in its ${manifestName} public list.`,
57
+ });
58
+ }
59
+
60
+ return {
61
+ ImportDeclaration(node) {
62
+ check(node.source, node.source?.value);
63
+ },
64
+ ExportNamedDeclaration(node) {
65
+ if (node.source) check(node.source, node.source.value);
66
+ },
67
+ ExportAllDeclaration(node) {
68
+ check(node.source, node.source?.value);
69
+ },
70
+ ImportExpression(node) {
71
+ if (node.source?.type === 'Literal') check(node.source, node.source.value);
72
+ },
73
+ };
74
+ },
75
+ };
76
+
77
+ const plugin = {
78
+ meta: { name: 'boundary-interfaces' },
79
+ rules: { 'boundary-interfaces': rule },
80
+ };
81
+
82
+ export default plugin;
package/src/lib.js ADDED
@@ -0,0 +1,94 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const jsonCache = new Map();
5
+ const aliasCache = new Map();
6
+
7
+ /** Tolerant JSON read (handles tsconfig comments + trailing commas). Cached. Returns null on failure. */
8
+ export function loadJson(file) {
9
+ if (jsonCache.has(file)) return jsonCache.get(file);
10
+ let result = null;
11
+ try {
12
+ const txt = readFileSync(file, 'utf8')
13
+ // ponytail: naive JSONC strip; swap for a JSON5 parser if a real tsconfig breaks it.
14
+ .replace(/\/\*[\s\S]*?\*\//g, '')
15
+ .replace(/(^|[^:"'])\/\/.*$/gm, '$1')
16
+ .replace(/,(\s*[}\]])/g, '$1');
17
+ result = JSON.parse(txt);
18
+ } catch {
19
+ result = null;
20
+ }
21
+ jsonCache.set(file, result);
22
+ return result;
23
+ }
24
+
25
+ /** Alias map from the project's tsconfig `compilerOptions.paths`. Cached per project root. */
26
+ export function getAliases(projectRoot) {
27
+ if (aliasCache.has(projectRoot)) return aliasCache.get(projectRoot);
28
+ const json = loadJson(path.join(projectRoot, 'tsconfig.json'));
29
+ const co = json?.compilerOptions ?? {};
30
+ const baseUrl = co.baseUrl ? path.resolve(projectRoot, co.baseUrl) : projectRoot;
31
+ const out = [];
32
+ for (const [key, vals] of Object.entries(co.paths ?? {})) {
33
+ if (!Array.isArray(vals) || vals.length === 0) continue;
34
+ out.push({
35
+ prefix: key.replace(/\*$/, ''),
36
+ target: path.resolve(baseUrl, vals[0].replace(/\*$/, '')),
37
+ });
38
+ }
39
+ out.sort((a, b) => b.prefix.length - a.prefix.length); // longest prefix wins
40
+ aliasCache.set(projectRoot, out);
41
+ return out;
42
+ }
43
+
44
+ /** Resolve an import specifier to an absolute path, or null for external/bare packages. */
45
+ export function resolveSpecifier(specifier, importerDir, projectRoot, aliases) {
46
+ if (specifier.startsWith('.')) return path.resolve(importerDir, specifier);
47
+ for (const { prefix, target } of aliases) {
48
+ if (specifier === prefix.replace(/\/$/, '')) return target;
49
+ if (specifier.startsWith(prefix)) return path.resolve(target, specifier.slice(prefix.length));
50
+ }
51
+ return null;
52
+ }
53
+
54
+ export function stripExt(p) {
55
+ return p.replace(/\.(m|c)?(ts|tsx|js|jsx)$/, '').replace(/\/index$/, '');
56
+ }
57
+
58
+ /** Walk up from a target file to the nearest ancestor dir holding `manifestName`. */
59
+ export function findContext(targetPath, projectRoot, manifestName) {
60
+ let dir = path.dirname(targetPath);
61
+ while (true) {
62
+ const manifest = path.join(dir, manifestName);
63
+ if (existsSync(manifest)) {
64
+ return { root: dir, publicList: loadJson(manifest)?.public ?? [] };
65
+ }
66
+ if (dir === projectRoot) return null;
67
+ const parent = path.dirname(dir);
68
+ if (parent === dir) return null;
69
+ dir = parent;
70
+ }
71
+ }
72
+
73
+ export function isInside(file, dir) {
74
+ const rel = path.relative(dir, file);
75
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
76
+ }
77
+
78
+ /** Does a context-relative path (no extension) match the public allow-list? Supports `/*` and `/**`. */
79
+ export function matchesPublic(relPath, publicList) {
80
+ const p = stripExt(relPath).split(path.sep).join('/');
81
+ for (let entry of publicList) {
82
+ entry = entry.replace(/\/+$/, '');
83
+ if (entry.endsWith('/**')) {
84
+ const prefix = entry.slice(0, -3);
85
+ if (p === prefix || p.startsWith(prefix + '/')) return true;
86
+ } else if (entry.endsWith('/*')) {
87
+ const prefix = entry.slice(0, -2);
88
+ if (p.startsWith(prefix + '/') && !p.slice(prefix.length + 1).includes('/')) return true;
89
+ } else if (p === entry) {
90
+ return true;
91
+ }
92
+ }
93
+ return false;
94
+ }