@commonplaces/oxlint-plugin 0.1.2 → 0.3.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,277 @@
1
+ import { defineRule } from "@oxlint/plugins";
2
+ import { Array, Number, Option, Predicate, Record, Schema } from "effect";
3
+ import { variableInScope } from "../syntax.js";
4
+ // A checked manifest consumer selects export conditions before configuring this
5
+ // pure rule. A false entry preserves a manifest's explicit null export fence.
6
+ const Options = Schema.Struct({
7
+ owners: Schema.Array(Schema.Struct({ id: Schema.String, root: Schema.String })),
8
+ workspaces: Schema.Array(Schema.Struct({
9
+ name: Schema.String,
10
+ owner: Schema.String,
11
+ exports: Schema.Record(Schema.String, Schema.Boolean),
12
+ })),
13
+ workspacePrefixes: Schema.Array(Schema.String),
14
+ forbiddenEdges: Schema.Array(Schema.Struct({ from: Schema.String, to: Schema.String })),
15
+ exceptions: Schema.Array(Schema.Struct({ fromFile: Schema.String, toModule: Schema.String })),
16
+ aliases: Schema.Array(Schema.Struct({ prefix: Schema.String, root: Schema.String })),
17
+ });
18
+ const normalise = (path) => path.replaceAll("\\", "/");
19
+ const inside = (path, root) => root === "." || path === root || path.startsWith(`${root}/`);
20
+ const cleanPath = (path) => path.length > 0 &&
21
+ !/[\\:*?#%]/u.test(path) &&
22
+ !path.includes("\u0000") &&
23
+ !path.startsWith("/") &&
24
+ Array.every(path.split("/"), (part) => part.length > 0 && part !== "." && part !== "..");
25
+ const rootPath = (path) => path === "." || cleanPath(path);
26
+ const packageName = (name) => /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u.test(name);
27
+ const unique = (values) => Array.dedupe(values).length === values.length;
28
+ const ownerAt = (options, path) => Array.head(Array.sort(Array.filter(options.owners, ({ root }) => inside(path, root)), (left, right) => Number.Order(right.root === "." ? 0 : right.root.length, left.root === "." ? 0 : left.root.length)));
29
+ const exportKey = (key) => key === "." ||
30
+ (key.startsWith("./") &&
31
+ key.split("*").length <= 2 &&
32
+ cleanPath(key.slice(2).replace("*", "pattern")));
33
+ const validSubpath = (key) => key === "." ||
34
+ (key.startsWith("./") &&
35
+ cleanPath(key.slice(2)) &&
36
+ !Array.contains(key.slice(2).split("/"), "node_modules"));
37
+ const patternMatch = (pattern, key) => {
38
+ const star = pattern.indexOf("*");
39
+ return (star >= 0 &&
40
+ key.startsWith(pattern.slice(0, star)) &&
41
+ key.endsWith(pattern.slice(star + 1)) &&
42
+ key.length >= pattern.length - 1);
43
+ };
44
+ const exported = (exports, key) => {
45
+ if (!validSubpath(key))
46
+ return false;
47
+ return Record.get(exports, key).pipe(Option.orElse(() => Array.head(Array.sort(Array.filter(Record.keys(exports), (pattern) => patternMatch(pattern, key)), (left, right) => Number.Order(right.indexOf("*"), left.indexOf("*")) ||
48
+ Number.Order(right.length, left.length))).pipe(Option.flatMap((pattern) => Record.get(exports, pattern)))), Option.getOrElse(() => false));
49
+ };
50
+ const workspaceAt = (options, address) => Array.findFirst(options.workspaces, ({ name }) => address === name || address.startsWith(`${name}/`));
51
+ const publicAddress = (options, address) => Option.exists(workspaceAt(options, address), (workspace) => exported(workspace.exports, address === workspace.name ? "." : `.${address.slice(workspace.name.length)}`));
52
+ const validOptions = (options) => Array.some(options.owners, ({ root }) => root === ".") &&
53
+ unique(Array.map(options.owners, ({ id }) => id)) &&
54
+ unique(Array.map(options.owners, ({ root }) => root)) &&
55
+ Array.every(options.owners, ({ id, root }) => id.length > 0 && rootPath(root)) &&
56
+ unique(Array.map(options.workspaces, ({ name }) => name)) &&
57
+ unique(Array.map(options.workspaces, ({ owner }) => owner)) &&
58
+ Array.every(options.workspaces, (workspace) => packageName(workspace.name) &&
59
+ Array.some(options.owners, ({ id }) => id === workspace.owner) &&
60
+ Array.every(Record.keys(workspace.exports), exportKey)) &&
61
+ unique(options.workspacePrefixes) &&
62
+ Array.every(options.workspacePrefixes, (prefix) => /^@[a-z0-9][a-z0-9._-]*\/$/u.test(prefix) || packageName(prefix)) &&
63
+ unique(Array.map(options.forbiddenEdges, ({ from, to }) => `${from}\u0000${to}`)) &&
64
+ Array.every(options.forbiddenEdges, ({ from, to }) => from !== to &&
65
+ Array.every([from, to], (endpoint) => Array.some(options.owners, ({ id }) => id === endpoint))) &&
66
+ unique(Array.map(options.aliases, ({ prefix }) => prefix)) &&
67
+ Array.every(options.aliases, ({ prefix, root }) => prefix.endsWith("/") &&
68
+ (prefix === "#/" || cleanPath(prefix.slice(0, -1))) &&
69
+ rootPath(root) &&
70
+ !Array.some(options.workspaces, ({ name }) => name.startsWith(prefix) || prefix.startsWith(`${name}/`))) &&
71
+ unique(Array.map(options.exceptions, ({ fromFile, toModule }) => `${fromFile}\u0000${toModule}`)) &&
72
+ Array.every(options.exceptions, ({ fromFile, toModule }) => cleanPath(fromFile) &&
73
+ publicAddress(options, toModule) &&
74
+ Option.exists(Option.all([ownerAt(options, fromFile), workspaceAt(options, toModule)]), ([from, to]) => Array.some(options.forbiddenEdges, (edge) => edge.from === from.id && edge.to === to.owner)));
75
+ // Scope-aware immutable string aliases only; no evaluation or type resolution.
76
+ const moduleAddress = (context, node, visited = []) => {
77
+ if (visited.length >= 64 || Array.some(visited, (previous) => previous === node))
78
+ return Option.none();
79
+ const next = [...visited, node];
80
+ if (node.type === "Literal" && Predicate.isString(node.value))
81
+ return Option.some(node.value);
82
+ if (node.type === "TemplateLiteral") {
83
+ return Option.all(Array.map(node.quasis, (quasi, index) => Option.all([
84
+ Option.fromNullishOr(quasi.value.cooked),
85
+ Array.get(node.expressions, index).pipe(Option.match({
86
+ onNone: () => Option.some(""),
87
+ onSome: (expression) => moduleAddress(context, expression, next),
88
+ })),
89
+ ]).pipe(Option.map(([text, expression]) => text + expression)))).pipe(Option.map(Array.join("")));
90
+ }
91
+ if (node.type === "BinaryExpression" && node.operator === "+") {
92
+ return Option.all([
93
+ moduleAddress(context, node.left, next),
94
+ moduleAddress(context, node.right, next),
95
+ ]).pipe(Option.map(([left, right]) => left + right));
96
+ }
97
+ if (node.type !== "Identifier")
98
+ return Option.none();
99
+ return variableInScope(context.sourceCode.getScope(node), node.name).pipe(Option.filter((variable) => variable.defs.length === 1 &&
100
+ Array.every(variable.references, (reference) => !reference.isWrite() || reference.init)), Option.flatMap((variable) => Array.head(variable.defs)), Option.flatMap((definition) => definition.node.type === "VariableDeclarator" &&
101
+ definition.node.id.type === "Identifier" &&
102
+ definition.parent?.type === "VariableDeclaration" &&
103
+ definition.parent.kind === "const"
104
+ ? Option.fromNullishOr(definition.node.init)
105
+ : Option.none()), Option.flatMap((initialiser) => moduleAddress(context, initialiser, next)));
106
+ };
107
+ const resolvePath = (base, address) => Array.join(Array.reduce(address.split("/"), base === "." || base === "" ? [] : base.split("/"), (parts, part) => part === ".."
108
+ ? parts.length === 0 || Option.contains(Array.last(parts), "..")
109
+ ? [...parts, ".."]
110
+ : Array.dropRight(parts, 1)
111
+ : part === "." || part === ""
112
+ ? parts
113
+ : [...parts, part]), "/");
114
+ const relativeFile = (filename, cwd) => {
115
+ const file = normalise(filename);
116
+ const root = normalise(cwd).replace(/\/$/u, "");
117
+ return file.startsWith(`${root}/`) ? Option.some(file.slice(root.length + 1)) : Option.none();
118
+ };
119
+ const fileAddress = (address) => address.replace(/[?#].*$/u, "");
120
+ const forbidden = (options, file, rawAddress, cwd) => {
121
+ const address = normalise(rawAddress);
122
+ const from = ownerAt(options, file);
123
+ const alias = Array.head(Array.sort(Array.filter(options.aliases, ({ prefix }) => address.startsWith(prefix)), (left, right) => Number.Order(right.prefix.length, left.prefix.length)));
124
+ if (Option.isSome(alias)) {
125
+ const target = resolvePath(alias.value.root, fileAddress(address.slice(alias.value.prefix.length)));
126
+ return (!cleanPath(target) ||
127
+ !inside(target, alias.value.root) ||
128
+ !Option.exists(Option.all([from, ownerAt(options, target)]), ([a, b]) => a.id === b.id));
129
+ }
130
+ if (address.startsWith(".") ||
131
+ address.startsWith("/") ||
132
+ /^[a-z]:\//iu.test(address) ||
133
+ address.startsWith("file:")) {
134
+ const target = address.startsWith(".")
135
+ ? Option.some(resolvePath(Array.join(Array.dropRight(file.split("/"), 1), "/"), fileAddress(address)))
136
+ : relativeFile(fileAddress(address), cwd).pipe(Option.map((path) => resolvePath(".", path)));
137
+ return !Option.exists(target, (path) => cleanPath(path) &&
138
+ Option.exists(Option.all([from, ownerAt(options, path)]), ([a, b]) => a.id === b.id));
139
+ }
140
+ return Option.match(workspaceAt(options, address), {
141
+ onNone: () => Array.some(options.workspacePrefixes, (prefix) => prefix.endsWith("/")
142
+ ? address.startsWith(prefix)
143
+ : address === prefix || address.startsWith(`${prefix}/`)),
144
+ onSome: (workspace) => !publicAddress(options, address) ||
145
+ (Option.exists(from, (owner) => Array.some(options.forbiddenEdges, (edge) => edge.from === owner.id && edge.to === workspace.owner)) &&
146
+ !Array.some(options.exceptions, (exception) => exception.fromFile === file && exception.toModule === address)),
147
+ });
148
+ };
149
+ export const workspaceImportBoundariesRule = defineRule({
150
+ meta: {
151
+ type: "problem",
152
+ docs: {
153
+ description: "Enforce checked workspace exports, owner edges and exact public-import exceptions.",
154
+ },
155
+ schema: [
156
+ {
157
+ type: "object",
158
+ additionalProperties: false,
159
+ required: [
160
+ "owners",
161
+ "workspaces",
162
+ "workspacePrefixes",
163
+ "forbiddenEdges",
164
+ "exceptions",
165
+ "aliases",
166
+ ],
167
+ properties: {
168
+ owners: {
169
+ type: "array",
170
+ items: {
171
+ type: "object",
172
+ additionalProperties: false,
173
+ required: ["id", "root"],
174
+ properties: { id: { type: "string" }, root: { type: "string" } },
175
+ },
176
+ },
177
+ workspaces: {
178
+ type: "array",
179
+ items: {
180
+ type: "object",
181
+ additionalProperties: false,
182
+ required: ["name", "owner", "exports"],
183
+ properties: {
184
+ name: { type: "string" },
185
+ owner: { type: "string" },
186
+ exports: { type: "object", additionalProperties: { type: "boolean" } },
187
+ },
188
+ },
189
+ },
190
+ workspacePrefixes: { type: "array", items: { type: "string" } },
191
+ forbiddenEdges: {
192
+ type: "array",
193
+ items: {
194
+ type: "object",
195
+ additionalProperties: false,
196
+ required: ["from", "to"],
197
+ properties: { from: { type: "string" }, to: { type: "string" } },
198
+ },
199
+ },
200
+ exceptions: {
201
+ type: "array",
202
+ items: {
203
+ type: "object",
204
+ additionalProperties: false,
205
+ required: ["fromFile", "toModule"],
206
+ properties: { fromFile: { type: "string" }, toModule: { type: "string" } },
207
+ },
208
+ },
209
+ aliases: {
210
+ type: "array",
211
+ items: {
212
+ type: "object",
213
+ additionalProperties: false,
214
+ required: ["prefix", "root"],
215
+ properties: { prefix: { type: "string" }, root: { type: "string" } },
216
+ },
217
+ },
218
+ },
219
+ },
220
+ ],
221
+ messages: {
222
+ options: "Supply valid checked workspace contracts, anchored owners and exact exceptions; boundary configuration is missing or inconsistent.",
223
+ boundary: "Use an allowed public workspace export. Direct owner crossings, private exports and forbidden owner edges are denied.",
224
+ },
225
+ },
226
+ create(context) {
227
+ const options = Array.head(context.options).pipe(Option.flatMap(Schema.decodeUnknownOption(Options, { onExcessProperty: "error" })), Option.filter(validOptions));
228
+ const file = relativeFile(context.filename, context.cwd);
229
+ const inspect = (node, source) => {
230
+ if (Option.isNone(options) || Option.isNone(file))
231
+ return;
232
+ Option.match(moduleAddress(context, source), {
233
+ onNone: () => undefined,
234
+ onSome: (address) => {
235
+ if (forbidden(options.value, file.value, address, context.cwd))
236
+ context.report({ node, messageId: "boundary" });
237
+ },
238
+ });
239
+ };
240
+ return {
241
+ Program(node) {
242
+ if (context.options.length !== 1 || Option.isNone(options) || Option.isNone(file))
243
+ context.report({ node, messageId: "options" });
244
+ },
245
+ ImportDeclaration(node) {
246
+ inspect(node, node.source);
247
+ },
248
+ ImportExpression(node) {
249
+ inspect(node, node.source);
250
+ },
251
+ ExportNamedDeclaration(node) {
252
+ if (node.source)
253
+ inspect(node, node.source);
254
+ },
255
+ ExportAllDeclaration(node) {
256
+ inspect(node, node.source);
257
+ },
258
+ TSImportType(node) {
259
+ inspect(node, node.source);
260
+ },
261
+ TSImportEqualsDeclaration(node) {
262
+ if (node.moduleReference.type === "TSExternalModuleReference")
263
+ inspect(node, node.moduleReference.expression);
264
+ },
265
+ CallExpression(node) {
266
+ if (node.callee.type === "Identifier" &&
267
+ node.callee.name === "require" &&
268
+ Option.isNone(variableInScope(context.sourceCode.getScope(node), "require"))) {
269
+ Option.match(Array.head(node.arguments), {
270
+ onNone: () => undefined,
271
+ onSome: (source) => inspect(node, source),
272
+ });
273
+ }
274
+ },
275
+ };
276
+ },
277
+ });
package/dist/package.d.ts CHANGED
@@ -4,6 +4,8 @@ declare const _default: {
4
4
  };
5
5
  rules: {
6
6
  "no-cross-package-source-imports": import("@oxlint/plugins").Rule;
7
+ "workspace-import-boundaries": import("@oxlint/plugins").Rule;
8
+ "no-redundant-rule-registrations": import("@oxlint/plugins").Rule;
7
9
  };
8
10
  };
9
11
  export default _default;
package/dist/package.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { defineRule } from "@oxlint/plugins";
2
2
  import { Array, Option, Predicate } from "effect";
3
+ import { workspaceImportBoundariesRule } from "./package/workspace-import-boundaries.js";
4
+ import { noRedundantRuleRegistrations } from "./config.js";
3
5
  const resolveRelative = (fileName, specifier) => Array.join(Array.reduce(specifier.split("/"), Array.dropRight(fileName.split("/"), 1), (segments, segment) => segment === ".."
4
6
  ? Array.dropRight(segments, 1)
5
7
  : segment === "." || segment.length === 0
@@ -46,5 +48,7 @@ export default {
46
48
  meta: { name: "package" },
47
49
  rules: {
48
50
  "no-cross-package-source-imports": noCrossPackageSourceImports,
51
+ "workspace-import-boundaries": workspaceImportBoundariesRule,
52
+ "no-redundant-rule-registrations": noRedundantRuleRegistrations,
49
53
  },
50
54
  };
@@ -1,3 +1,4 @@
1
+ import { subprocessPipeCapture } from "./subprocess-pipe-capture.js";
1
2
  import { defineRule, eslintCompatPlugin } from "@oxlint/plugins";
2
3
  import * as Array from "effect/Array";
3
4
  import * as Function from "effect/Function";
@@ -720,6 +721,7 @@ const scopedImportedReference = (context, expression, visited = [], acceptsModul
720
721
  }
721
722
  if (declaration.id.type === "ObjectPattern") {
722
723
  return Array.findFirst((property) => property.type === "Property" &&
724
+ (!property.computed || property.key.type === "Literal") &&
723
725
  property.value.type === "Identifier" &&
724
726
  property.value.name === name)(declaration.id.properties).pipe(Option.flatMap((property) => {
725
727
  if (property.type === "Property") {
@@ -1797,6 +1799,61 @@ const websiteDataProxy = defineRule({
1797
1799
  // Structure ownership: facts with one owner may be written only in that owner.
1798
1800
  // Owner paths are exact and relative to Oxlint's working directory.
1799
1801
  const testFile = (filename) => /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(filename);
1802
+ const vitestModule = (source) => source === "vitest" || source === "@effect/vitest";
1803
+ // Keep lexical import provenance through immutable aliases, including static
1804
+ // assertion members. Unknown, shadowed and reassigned bindings are not trusted.
1805
+ const testReference = (context, node, visited = []) => {
1806
+ if (Array.some(visited, (previous) => previous === node))
1807
+ return Option.none();
1808
+ const next = [...visited, node];
1809
+ const imported = scopedImportedReference(context, node, [], vitestModule);
1810
+ if (Option.isSome(imported)) {
1811
+ return Option.some({ path: Option.toArray(imported.value.name) });
1812
+ }
1813
+ if (node.type === "MemberExpression" && (!node.computed || node.property.type === "Literal")) {
1814
+ return Option.all([
1815
+ testReference(context, node.object, next),
1816
+ propertyName(node.property),
1817
+ ]).pipe(Option.map(([reference, name]) => ({ path: [...reference.path, name] })));
1818
+ }
1819
+ if (node.type !== "Identifier")
1820
+ return Option.none();
1821
+ return scopedVariable(context.sourceCode.getScope(node), node.name).pipe(Option.flatMap(constantDeclaration), Option.flatMap((declaration) => Option.fromNullishOr(declaration.init).pipe(Option.flatMap((initialiser) => testReference(context, initialiser, next)), Option.flatMap((reference) => {
1822
+ if (declaration.id.type === "Identifier")
1823
+ return Option.some(reference);
1824
+ if (declaration.id.type !== "ObjectPattern")
1825
+ return Option.none();
1826
+ return Array.findFirst(declaration.id.properties, (property) => property.type === "Property" &&
1827
+ (!property.computed || property.key.type === "Literal") &&
1828
+ property.value.type === "Identifier" &&
1829
+ property.value.name === node.name).pipe(Option.flatMap((property) => {
1830
+ if (property.type === "Property")
1831
+ return propertyName(property.key);
1832
+ return Option.none();
1833
+ }), Option.map((name) => ({ path: [...reference.path, name] })));
1834
+ }))));
1835
+ };
1836
+ const noRuntimeTypeAssertions = defineRule({
1837
+ meta: {
1838
+ type: "problem",
1839
+ schema: [],
1840
+ messages: {
1841
+ typeAssertion: "Move compile-only type assertions to a .typecheck.ts or .test-d.ts file checked by TypeScript; runtime tests must exercise behaviour.",
1842
+ },
1843
+ },
1844
+ create(context) {
1845
+ if (!testFile(context.filename))
1846
+ return {};
1847
+ return {
1848
+ CallExpression(node) {
1849
+ if (Option.exists(testReference(context, node.callee), ({ path }) => path.length === 1 &&
1850
+ Array.some(path, (name) => name === "expectTypeOf" || name === "assertType"))) {
1851
+ context.report({ node, messageId: "typeAssertion" });
1852
+ }
1853
+ },
1854
+ };
1855
+ },
1856
+ });
1800
1857
  const ownedBy = (context, files) => Array.some(files, (file) => exactOwnedFile(context.filename, context.cwd, file));
1801
1858
  const ownerFilesSchema = { type: "array", items: { type: "string", minLength: 1 } };
1802
1859
  const escapePattern = (text) => text.replaceAll(/[.*+?^${}()|[\]\\/]/gu, "\\$&");
@@ -1832,7 +1889,98 @@ const PublicHostOptions = Schema.Struct({
1832
1889
  hosts: Schema.Array(Schema.String),
1833
1890
  allowedFiles: Schema.Array(Schema.String),
1834
1891
  allowTestFiles: Schema.Boolean,
1892
+ allowTestAssertions: Schema.optionalKey(Schema.Boolean),
1893
+ allowedDeclarations: Schema.optionalKey(Schema.Array(Schema.Struct({ file: Schema.String, names: Schema.Array(Schema.String) }))),
1835
1894
  });
1895
+ const topLevelConstant = (node) => {
1896
+ const parent = node.parent;
1897
+ if (!parent)
1898
+ return Option.none();
1899
+ if (parent.type === "ArrowFunctionExpression" ||
1900
+ parent.type === "FunctionExpression" ||
1901
+ parent.type === "FunctionDeclaration")
1902
+ return Option.none();
1903
+ if (parent.type !== "VariableDeclarator")
1904
+ return topLevelConstant(parent);
1905
+ if (parent.init !== node || parent.id.type !== "Identifier")
1906
+ return Option.none();
1907
+ const declaration = parent.parent;
1908
+ if (declaration?.type !== "VariableDeclaration" || declaration.kind !== "const") {
1909
+ return Option.none();
1910
+ }
1911
+ const statement = declaration.parent;
1912
+ if (statement?.type === "Program" ||
1913
+ (statement?.type === "ExportNamedDeclaration" && statement.parent.type === "Program"))
1914
+ return Option.some(parent.id.name);
1915
+ return Option.none();
1916
+ };
1917
+ const expectedMatchers = [
1918
+ "toBe",
1919
+ "toEqual",
1920
+ "toStrictEqual",
1921
+ "toMatchObject",
1922
+ "toContain",
1923
+ "toContainEqual",
1924
+ "toMatch",
1925
+ ];
1926
+ const expectedAssertions = [
1927
+ "equal",
1928
+ "notEqual",
1929
+ "strictEqual",
1930
+ "notStrictEqual",
1931
+ "deepEqual",
1932
+ "notDeepEqual",
1933
+ "deepStrictEqual",
1934
+ "notDeepStrictEqual",
1935
+ "include",
1936
+ "notInclude",
1937
+ "deepInclude",
1938
+ "notDeepInclude",
1939
+ "match",
1940
+ "notMatch",
1941
+ ];
1942
+ const importedExpectation = (context, node) => {
1943
+ if (node.type === "CallExpression") {
1944
+ return Option.exists(testReference(context, node.callee), ({ path }) => (path.length === 1 && Array.contains(path, "expect")) ||
1945
+ (path.length === 2 &&
1946
+ Option.contains(Array.head(path), "expect") &&
1947
+ Option.contains(Array.get(path, 1), "soft")));
1948
+ }
1949
+ return (node.type === "MemberExpression" &&
1950
+ (!node.computed || node.property.type === "Literal") &&
1951
+ Option.exists(propertyName(node.property), (name) => Array.contains(["not", "resolves", "rejects"], name)) &&
1952
+ importedExpectation(context, node.object));
1953
+ };
1954
+ // Only static expected-value data is exempt. A call or constructor between a
1955
+ // literal and the assertion is setup/computation, even inside an expected arg.
1956
+ const assertionExpectedValue = (context, node) => {
1957
+ const parent = node.parent;
1958
+ if (!parent)
1959
+ return false;
1960
+ if (parent.type === "CallExpression") {
1961
+ const expected = Array.get(parent.arguments, 1);
1962
+ if (Option.exists(expected, (argument) => argument === node) &&
1963
+ Option.exists(testReference(context, parent.callee), ({ path }) => path.length === 2 &&
1964
+ Option.contains(Array.head(path), "assert") &&
1965
+ Option.exists(Array.get(path, 1), (name) => Array.contains(expectedAssertions, name))))
1966
+ return true;
1967
+ return (parent.callee.type === "MemberExpression" &&
1968
+ (!parent.callee.computed || parent.callee.property.type === "Literal") &&
1969
+ Option.exists(Array.head(parent.arguments), (argument) => argument === node) &&
1970
+ Option.exists(propertyName(parent.callee.property), (name) => Array.contains(expectedMatchers, name)) &&
1971
+ importedExpectation(context, parent.callee.object));
1972
+ }
1973
+ if (parent.type === "ArrayExpression" ||
1974
+ parent.type === "ObjectExpression" ||
1975
+ parent.type === "TemplateLiteral" ||
1976
+ (parent.type === "Property" &&
1977
+ parent.kind === "init" &&
1978
+ !parent.method &&
1979
+ (parent.value === node ||
1980
+ (parent.key === node && (!parent.computed || node.type === "Literal")))))
1981
+ return assertionExpectedValue(context, parent);
1982
+ return false;
1983
+ };
1836
1984
  // A configured host or any subdomain of it, as a whole hostname.
1837
1985
  const hostPattern = (host) => new RegExp(`(?:^|[^a-z0-9-])(?:[a-z0-9-]+\\.)*${escapePattern(host)}(?![a-z0-9-])`, "iu");
1838
1986
  const publicHostLiterals = defineRule({
@@ -1849,6 +1997,16 @@ const publicHostLiterals = defineRule({
1849
1997
  },
1850
1998
  allowedFiles: ownerFilesSchema,
1851
1999
  allowTestFiles: { type: "boolean" },
2000
+ allowTestAssertions: { type: "boolean" },
2001
+ allowedDeclarations: {
2002
+ type: "array",
2003
+ items: {
2004
+ type: "object",
2005
+ properties: { file: { type: "string", minLength: 1 }, names: ownerFilesSchema },
2006
+ required: ["file", "names"],
2007
+ additionalProperties: false,
2008
+ },
2009
+ },
1852
2010
  },
1853
2011
  required: ["hosts", "allowedFiles", "allowTestFiles"],
1854
2012
  additionalProperties: false,
@@ -1860,16 +2018,24 @@ const publicHostLiterals = defineRule({
1860
2018
  },
1861
2019
  create(context) {
1862
2020
  const options = Array.head(context.options).pipe(Option.flatMap(Schema.decodeUnknownOption(PublicHostOptions)));
1863
- if (Option.isNone(options) ||
1864
- ownedBy(context, options.value.allowedFiles) ||
1865
- (options.value.allowTestFiles && testFile(context.filename))) {
2021
+ if (Option.isNone(options) || (options.value.allowTestFiles && testFile(context.filename))) {
1866
2022
  return {};
1867
2023
  }
2024
+ const declarationOwner = Array.findFirst(options.value.allowedDeclarations ?? [], ({ file }) => ownedBy(context, [file]));
2025
+ const fileOwner = ownedBy(context, options.value.allowedFiles);
2026
+ if (fileOwner && Option.isNone(declarationOwner))
2027
+ return {};
1868
2028
  const patterns = Array.map(options.value.hosts, (host) => [
1869
2029
  host,
1870
2030
  hostPattern(host),
1871
2031
  ]);
1872
2032
  return textVisitors((node, text) => {
2033
+ if ((fileOwner &&
2034
+ Option.exists(declarationOwner, ({ names }) => Option.exists(topLevelConstant(node), (name) => Array.contains(names, name)))) ||
2035
+ (options.value.allowTestAssertions === true &&
2036
+ testFile(context.filename) &&
2037
+ assertionExpectedValue(context, node)))
2038
+ return;
1873
2039
  Option.match(Array.findFirst(patterns, ([, pattern]) => pattern.test(text)), {
1874
2040
  onNone: Function.constVoid,
1875
2041
  onSome: ([host]) => {
@@ -2357,6 +2523,7 @@ export default eslintCompatPlugin({
2357
2523
  "no-native-client-replica": noNativeClientReplica,
2358
2524
  "website-rpc-boundary": websiteRpcBoundary,
2359
2525
  "static-layers": staticLayers,
2526
+ "subprocess-pipe-capture": subprocessPipeCapture,
2360
2527
  "no-unchecked-index": noUncheckedIndex,
2361
2528
  "no-native-at": noNativeAt,
2362
2529
  "runtime-file-convention": runtimeFileConvention,
@@ -2368,6 +2535,7 @@ export default eslintCompatPlugin({
2368
2535
  "no-unchecked-json": noUncheckedJson,
2369
2536
  "no-runtime-outside-boundary": noRuntimeOutsideBoundary,
2370
2537
  "no-native-work": noNativeWork,
2538
+ "no-runtime-type-assertions": noRuntimeTypeAssertions,
2371
2539
  "no-imperative-collections": noImperativeCollections,
2372
2540
  "public-host-literals": publicHostLiterals,
2373
2541
  "route-literal-ownership": routeLiteralOwnership,
@@ -0,0 +1,10 @@
1
+ import type { Context, ESTree } from "@oxlint/plugins";
2
+ import { Option } from "effect";
3
+ export type Channel = "exitCode" | "stdout" | "stderr";
4
+ export interface Capture {
5
+ readonly handle: ESTree.Node;
6
+ readonly channel: Channel;
7
+ }
8
+ export declare const memberName: (node: ESTree.MemberExpression) => Option.None<string> | Option.Some<string>;
9
+ export declare const nonPipe: (context: Context, handle: ESTree.Node, channel: Channel) => boolean;
10
+ export declare const channelMember: (context: Context, expression: ESTree.Node, visited?: readonly ESTree.Node[]) => Option.Option<Capture>;
@@ -0,0 +1,56 @@
1
+ import { Array, Option } from "effect";
2
+ import { constant, operation, valueOf } from "./native-reference.js";
3
+ import { propertyName, variableInScope } from "./syntax.js";
4
+ export const memberName = (node) => node.computed && node.property.type !== "Literal" ? Option.none() : propertyName(node.property);
5
+ // A type annotation or a lookalike spawn method never establishes provider identity.
6
+ const processHandle = (context, expression) => valueOf(context, expression).pipe(Option.filter((node) => node.type === "YieldExpression"), Option.filter((node) => node.delegate), Option.flatMap((node) => Option.fromNullishOr(node.argument)), Option.filter((node) => node.type === "CallExpression"), Option.filter((node) => node.callee.type === "MemberExpression" &&
7
+ Option.contains(memberName(node.callee), "spawn")), Option.filter((node) => node.callee.type === "MemberExpression" &&
8
+ valueOf(context, node.callee.object).pipe(Option.filter((value) => value.type === "YieldExpression"), Option.filter((value) => value.delegate), Option.flatMap((value) => Option.fromNullishOr(value.argument)), Option.exists((service) => operation(context, service, "effect/process/ChildProcessSpawner", "ChildProcessSpawner")))));
9
+ const field = (context, expression, name) => valueOf(context, expression).pipe(Option.filter((node) => node.type === "ObjectExpression"), Option.filter((node) => Array.every(node.properties, (property) => property.type === "Property" &&
10
+ property.kind === "init" &&
11
+ !property.computed &&
12
+ !Option.contains(propertyName(property.key), "__proto__"))), Option.flatMap((node) => Array.findLast(node.properties, (property) => property.type === "Property" && Option.contains(propertyName(property.key), name))), Option.filter((property) => property.type === "Property"), Option.flatMap((property) => valueOf(context, property.value)));
13
+ export const nonPipe = (context, handle, channel) => {
14
+ if (channel === "exitCode" || handle.type !== "CallExpression")
15
+ return false;
16
+ const command = Array.head(handle.arguments).pipe(Option.flatMap((node) => valueOf(context, node)));
17
+ const options = command.pipe(Option.flatMap((node) => {
18
+ if (node.type === "TaggedTemplateExpression" &&
19
+ node.tag.type === "CallExpression" &&
20
+ operation(context, node.tag.callee, "effect/process/ChildProcess", "make"))
21
+ return Array.head(node.tag.arguments);
22
+ if (node.type !== "CallExpression" ||
23
+ !operation(context, node.callee, "effect/process/ChildProcess", "make"))
24
+ return Option.none();
25
+ return Array.get(node.arguments, 1).pipe(Option.flatMap((second) => valueOf(context, second)), Option.flatMap((second) => second.type === "ObjectExpression"
26
+ ? Option.some(second)
27
+ : second.type === "ArrayExpression"
28
+ ? Array.get(node.arguments, 2)
29
+ : Option.none()));
30
+ }));
31
+ return options.pipe(Option.flatMap((node) => field(context, node, channel)), Option.flatMap((node) => node.type === "ObjectExpression" ? field(context, node, "stream") : Option.some(node)), Option.exists((node) => node.type === "Literal" && (node.value === "inherit" || node.value === "ignore")));
32
+ };
33
+ export const channelMember = (context, expression, visited = []) => {
34
+ if (Array.some(visited, (node) => node === expression))
35
+ return Option.none();
36
+ const next = [...visited, expression];
37
+ if (expression.type === "Identifier") {
38
+ const declaration = variableInScope(context.sourceCode.getScope(expression), expression.name).pipe(Option.flatMap(constant));
39
+ return declaration.pipe(Option.flatMap((node) => {
40
+ if (node.id.type === "Identifier")
41
+ return Option.fromNullishOr(node.init).pipe(Option.flatMap((initialiser) => channelMember(context, initialiser, next)));
42
+ if (node.id.type !== "ObjectPattern" || !node.init)
43
+ return Option.none();
44
+ const handle = processHandle(context, node.init);
45
+ return Array.findFirst(node.id.properties, (property) => property.type === "Property" &&
46
+ !property.computed &&
47
+ property.value.type === "Identifier" &&
48
+ property.value.name === expression.name).pipe(Option.filter((property) => property.type === "Property"), Option.flatMap((property) => propertyName(property.key)), Option.filter((name) => name === "exitCode" || name === "stdout" || name === "stderr"), Option.flatMap((channel) => Option.map(handle, (process) => ({ handle: process, channel }))));
49
+ }));
50
+ }
51
+ if (expression.type === "TSAsExpression" || expression.type === "TSSatisfiesExpression")
52
+ return channelMember(context, expression.expression, next);
53
+ if (expression.type !== "MemberExpression")
54
+ return Option.none();
55
+ return memberName(expression).pipe(Option.filter((name) => name === "exitCode" || name === "stdout" || name === "stderr"), Option.flatMap((channel) => Option.map(processHandle(context, expression.object), (handle) => ({ handle, channel }))));
56
+ };
@@ -0,0 +1 @@
1
+ export declare const subprocessPipeCapture: import("@oxlint/plugins").Rule;