@shivaedev/quality 0.18.0 → 0.20.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.
Files changed (227) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +136 -544
  3. package/biome/declarations.json +0 -45
  4. package/biome/plugins/constant-names.grit +1 -1
  5. package/biome/plugins/effect-fn-functions.grit +1 -1
  6. package/biome/preset.json +16 -9
  7. package/dist/biome/nativeEffectCall.d.ts +4 -0
  8. package/dist/biome/nativeEffectCall.d.ts.map +1 -0
  9. package/dist/biome/nativeEffectCall.js +34 -0
  10. package/dist/biome/nativeEffectCall.js.map +1 -0
  11. package/dist/biome/recognizeDiagnostics.d.ts +3 -0
  12. package/dist/biome/recognizeDiagnostics.d.ts.map +1 -0
  13. package/dist/biome/recognizeDiagnostics.js +58 -0
  14. package/dist/biome/recognizeDiagnostics.js.map +1 -0
  15. package/dist/biome/report.d.ts +10 -0
  16. package/dist/biome/report.d.ts.map +1 -1
  17. package/dist/biome/report.js +23 -5
  18. package/dist/biome/report.js.map +1 -1
  19. package/dist/biome/syntax.d.ts +12 -0
  20. package/dist/biome/syntax.d.ts.map +1 -0
  21. package/dist/biome/syntax.js +29 -0
  22. package/dist/biome/syntax.js.map +1 -0
  23. package/dist/cli/args.d.ts +1 -1
  24. package/dist/cli/args.d.ts.map +1 -1
  25. package/dist/cli/args.js +2 -2
  26. package/dist/cli/baseline.d.ts +1 -1
  27. package/dist/cli/baseline.d.ts.map +1 -1
  28. package/dist/cli/baseline.js.map +1 -1
  29. package/dist/cli/fix.d.ts +6 -6
  30. package/dist/cli/fix.d.ts.map +1 -1
  31. package/dist/cli/fix.js +1 -1
  32. package/dist/cli/fix.js.map +1 -1
  33. package/dist/cli/run-main.d.ts +1 -1
  34. package/dist/cli/run-main.d.ts.map +1 -1
  35. package/dist/cli/run-main.js.map +1 -1
  36. package/dist/config/decode.d.ts +3 -7
  37. package/dist/config/decode.d.ts.map +1 -1
  38. package/dist/config/decode.js +5 -3
  39. package/dist/config/decode.js.map +1 -1
  40. package/dist/config/load.d.ts +0 -2
  41. package/dist/config/load.d.ts.map +1 -1
  42. package/dist/config/load.js +11 -7
  43. package/dist/config/load.js.map +1 -1
  44. package/dist/config/resolve.d.ts.map +1 -1
  45. package/dist/config/resolve.js +8 -12
  46. package/dist/config/resolve.js.map +1 -1
  47. package/dist/config.d.ts +10 -16
  48. package/dist/config.d.ts.map +1 -1
  49. package/dist/config.js.map +1 -1
  50. package/dist/decoded.d.ts +4 -4
  51. package/dist/decoded.d.ts.map +1 -1
  52. package/dist/decoded.js.map +1 -1
  53. package/dist/engine/session.js.map +1 -1
  54. package/dist/engine/violation.d.ts +1 -1
  55. package/dist/engine/violation.d.ts.map +1 -1
  56. package/dist/engine/violation.js.map +1 -1
  57. package/dist/git/command.d.ts +1 -1
  58. package/dist/git/command.d.ts.map +1 -1
  59. package/dist/git/command.js +1 -1
  60. package/dist/git/command.js.map +1 -1
  61. package/dist/hooks/install.d.ts +1 -1
  62. package/dist/hooks/install.d.ts.map +1 -1
  63. package/dist/hooks/location.d.ts +1 -1
  64. package/dist/hooks/location.d.ts.map +1 -1
  65. package/dist/hooks/preCommit.d.ts +3 -3
  66. package/dist/hooks/preCommit.d.ts.map +1 -1
  67. package/dist/hooks/preCommit.js +25 -35
  68. package/dist/hooks/preCommit.js.map +1 -1
  69. package/dist/hooks/uninstall.d.ts +1 -1
  70. package/dist/hooks/uninstall.d.ts.map +1 -1
  71. package/dist/imports/browserGlobals.d.ts +3 -0
  72. package/dist/imports/browserGlobals.d.ts.map +1 -0
  73. package/dist/imports/browserGlobals.js +125 -0
  74. package/dist/imports/browserGlobals.js.map +1 -0
  75. package/dist/imports/browserNames.d.ts +2 -0
  76. package/dist/imports/browserNames.d.ts.map +1 -0
  77. package/dist/imports/browserNames.js +78 -0
  78. package/dist/imports/browserNames.js.map +1 -0
  79. package/dist/imports/fences/check.d.ts +4 -0
  80. package/dist/imports/fences/check.d.ts.map +1 -0
  81. package/dist/imports/fences/check.js +10 -0
  82. package/dist/imports/fences/check.js.map +1 -0
  83. package/dist/imports/fences/declarations.d.ts +5 -0
  84. package/dist/imports/fences/declarations.d.ts.map +1 -0
  85. package/dist/imports/fences/declarations.js +29 -0
  86. package/dist/imports/fences/declarations.js.map +1 -0
  87. package/dist/imports/fences/discover.d.ts +5 -0
  88. package/dist/imports/fences/discover.d.ts.map +1 -0
  89. package/dist/imports/fences/discover.js +41 -0
  90. package/dist/imports/fences/discover.js.map +1 -0
  91. package/dist/imports/graph.js +2 -2
  92. package/dist/imports/graph.js.map +1 -1
  93. package/dist/imports/isTooling.d.ts +2 -0
  94. package/dist/imports/isTooling.d.ts.map +1 -0
  95. package/dist/imports/isTooling.js +9 -0
  96. package/dist/imports/isTooling.js.map +1 -0
  97. package/dist/imports/projects.js.map +1 -1
  98. package/dist/imports/runtime.d.ts +5 -0
  99. package/dist/imports/runtime.d.ts.map +1 -0
  100. package/dist/imports/runtime.js +57 -0
  101. package/dist/imports/runtime.js.map +1 -0
  102. package/dist/imports/workspace.d.ts +1 -1
  103. package/dist/imports/workspace.d.ts.map +1 -1
  104. package/dist/imports/workspace.js +5 -1
  105. package/dist/imports/workspace.js.map +1 -1
  106. package/dist/inventory/collect.d.ts.map +1 -1
  107. package/dist/inventory/collect.js +8 -1
  108. package/dist/inventory/collect.js.map +1 -1
  109. package/dist/inventory/trackedPaths.d.ts +3 -0
  110. package/dist/inventory/trackedPaths.d.ts.map +1 -0
  111. package/dist/inventory/trackedPaths.js +26 -0
  112. package/dist/inventory/trackedPaths.js.map +1 -0
  113. package/dist/inventory/walk.d.ts +4 -4
  114. package/dist/inventory/walk.d.ts.map +1 -1
  115. package/dist/inventory/walk.js +32 -12
  116. package/dist/inventory/walk.js.map +1 -1
  117. package/dist/rules/built-in.d.ts +2 -3
  118. package/dist/rules/built-in.d.ts.map +1 -1
  119. package/dist/rules/built-in.js +6 -0
  120. package/dist/rules/built-in.js.map +1 -1
  121. package/dist/rules/comments/max-per-file.js +1 -1
  122. package/dist/rules/comments/max-per-file.js.map +1 -1
  123. package/dist/rules/comments/no-environment-pragma.js +1 -1
  124. package/dist/rules/comments/no-environment-pragma.js.map +1 -1
  125. package/dist/rules/files/folderNames.js +1 -1
  126. package/dist/rules/files/folderNames.js.map +1 -1
  127. package/dist/rules/files/otherNames.d.ts.map +1 -1
  128. package/dist/rules/files/otherNames.js +1 -10
  129. package/dist/rules/files/otherNames.js.map +1 -1
  130. package/dist/rules/imports/bundleInputs.d.ts +7 -0
  131. package/dist/rules/imports/bundleInputs.d.ts.map +1 -0
  132. package/dist/rules/imports/bundleInputs.js +77 -0
  133. package/dist/rules/imports/bundleInputs.js.map +1 -0
  134. package/dist/rules/imports/fences.d.ts +1 -2
  135. package/dist/rules/imports/fences.d.ts.map +1 -1
  136. package/dist/rules/imports/fences.js +4 -14
  137. package/dist/rules/imports/fences.js.map +1 -1
  138. package/dist/rules/imports/manifest.d.ts +2 -0
  139. package/dist/rules/imports/manifest.d.ts.map +1 -0
  140. package/dist/rules/imports/manifest.js +92 -0
  141. package/dist/rules/imports/manifest.js.map +1 -0
  142. package/dist/rules/imports/noTestCode.d.ts +2 -0
  143. package/dist/rules/imports/noTestCode.d.ts.map +1 -0
  144. package/dist/rules/imports/noTestCode.js +17 -0
  145. package/dist/rules/imports/noTestCode.js.map +1 -0
  146. package/dist/rules/imports/runtime.d.ts +2 -0
  147. package/dist/rules/imports/runtime.d.ts.map +1 -0
  148. package/dist/rules/imports/runtime.js +45 -0
  149. package/dist/rules/imports/runtime.js.map +1 -0
  150. package/dist/rules/max-lines.d.ts +0 -1
  151. package/dist/rules/max-lines.d.ts.map +1 -1
  152. package/dist/rules/max-lines.js +3 -10
  153. package/dist/rules/max-lines.js.map +1 -1
  154. package/dist/rules/suppressions/biome-overrides.d.ts.map +1 -1
  155. package/dist/rules/suppressions/biome-overrides.js +21 -4
  156. package/dist/rules/suppressions/biome-overrides.js.map +1 -1
  157. package/dist/rules/suppressions/biomeRecommended.js +2 -2
  158. package/dist/rules/suppressions/biomeRecommended.js.map +1 -1
  159. package/dist/rules/suppressions/no-inline.js +1 -1
  160. package/dist/rules/suppressions/no-inline.js.map +1 -1
  161. package/dist/standard-schema.d.ts +1 -1
  162. package/dist/standard-schema.d.ts.map +1 -1
  163. package/dist/standard-schema.js +10 -2
  164. package/dist/standard-schema.js.map +1 -1
  165. package/dist/testProjects.d.ts +3 -0
  166. package/dist/testProjects.d.ts.map +1 -0
  167. package/dist/testProjects.js +25 -0
  168. package/dist/testProjects.js.map +1 -0
  169. package/docs/debt-and-hooks.md +90 -0
  170. package/docs/modules.md +253 -0
  171. package/docs/naming.md +126 -0
  172. package/docs/north-star.md +61 -0
  173. package/docs/roadmap.md +37 -0
  174. package/docs/rules.md +310 -0
  175. package/docs/tooling.md +105 -0
  176. package/package.json +17 -14
  177. package/src/biome/nativeEffectCall.ts +45 -0
  178. package/src/biome/recognizeDiagnostics.ts +67 -0
  179. package/src/biome/report.ts +24 -6
  180. package/src/biome/syntax.ts +36 -0
  181. package/src/cli/args.ts +2 -2
  182. package/src/cli/baseline.ts +3 -3
  183. package/src/cli/fix.ts +13 -13
  184. package/src/cli/run-main.ts +1 -1
  185. package/src/config/decode.ts +7 -3
  186. package/src/config/load.ts +15 -9
  187. package/src/config/resolve.ts +10 -15
  188. package/src/config.ts +8 -15
  189. package/src/decoded.ts +3 -3
  190. package/src/engine/session.ts +3 -3
  191. package/src/engine/violation.ts +2 -2
  192. package/src/git/command.ts +1 -1
  193. package/src/hooks/preCommit.ts +31 -40
  194. package/src/imports/browserGlobals.ts +142 -0
  195. package/src/imports/browserNames.ts +80 -0
  196. package/src/imports/fences/check.ts +12 -0
  197. package/src/imports/fences/declarations.ts +38 -0
  198. package/src/imports/fences/discover.ts +37 -0
  199. package/src/imports/graph.ts +2 -2
  200. package/src/imports/isTooling.ts +11 -0
  201. package/src/imports/projects.ts +2 -2
  202. package/src/imports/runtime.ts +63 -0
  203. package/src/imports/workspace.ts +7 -2
  204. package/src/inventory/collect.ts +8 -1
  205. package/src/inventory/trackedPaths.ts +28 -0
  206. package/src/inventory/walk.ts +65 -36
  207. package/src/rules/built-in.ts +6 -0
  208. package/src/rules/comments/max-per-file.ts +1 -1
  209. package/src/rules/comments/no-environment-pragma.ts +1 -1
  210. package/src/rules/files/folderNames.ts +1 -1
  211. package/src/rules/files/otherNames.ts +1 -10
  212. package/src/rules/imports/bundleInputs.ts +94 -0
  213. package/src/rules/imports/fences.ts +4 -16
  214. package/src/rules/imports/manifest.ts +108 -0
  215. package/src/rules/imports/noTestCode.ts +18 -0
  216. package/src/rules/imports/runtime.ts +47 -0
  217. package/src/rules/max-lines.ts +4 -10
  218. package/src/rules/suppressions/biome-overrides.ts +31 -4
  219. package/src/rules/suppressions/biomeRecommended.ts +2 -2
  220. package/src/rules/suppressions/no-inline.ts +1 -1
  221. package/src/standard-schema.ts +11 -3
  222. package/src/testProjects.ts +30 -0
  223. package/dist/vitest.d.ts +0 -6
  224. package/dist/vitest.d.ts.map +0 -1
  225. package/dist/vitest.js +0 -55
  226. package/dist/vitest.js.map +0 -1
  227. package/src/vitest.ts +0 -60
@@ -0,0 +1,142 @@
1
+ import ts from "typescript";
2
+ import type { Finding, SourceFile } from "#rule.ts";
3
+ import { parse } from "#rules/syntax.ts";
4
+ import { browserNames } from "./browserNames.ts";
5
+
6
+ function checkerOf(source: ts.SourceFile): ts.TypeChecker {
7
+ const options: ts.CompilerOptions = { allowJs: true, noLib: true, noResolve: true };
8
+ const host = ts.createCompilerHost(options);
9
+ host.getSourceFile = (path) => (path === source.fileName ? source : undefined);
10
+ return ts.createProgram([source.fileName], options, host).getTypeChecker();
11
+ }
12
+
13
+ function unbound(checker: ts.TypeChecker, node: ts.Identifier): boolean {
14
+ return (checker.getSymbolAtLocation(node)?.declarations ?? []).length === 0;
15
+ }
16
+
17
+ function globalThisRoot(expression: ts.Expression, checker: ts.TypeChecker): boolean {
18
+ let root = expression;
19
+ while (
20
+ ts.isParenthesizedExpression(root)
21
+ || ts.isAsExpression(root)
22
+ || ts.isTypeAssertionExpression(root)
23
+ || ts.isNonNullExpression(root)
24
+ || ts.isSatisfiesExpression(root)
25
+ ) {
26
+ root = root.expression;
27
+ }
28
+ return ts.isIdentifier(root) && root.text === "globalThis" && unbound(checker, root);
29
+ }
30
+
31
+ function qualifiedName(node: ts.PropertyAccessExpression | ts.ElementAccessExpression, checker: ts.TypeChecker): string | undefined {
32
+ const root = node.expression;
33
+ if (!globalThisRoot(root, checker)) {
34
+ return undefined;
35
+ }
36
+ if (ts.isPropertyAccessExpression(node)) {
37
+ return node.name.text;
38
+ }
39
+ return ts.isStringLiteral(node.argumentExpression) ? node.argumentExpression.text : undefined;
40
+ }
41
+
42
+ function bindingName(node: ts.Identifier): boolean {
43
+ const parent = node.parent;
44
+ return ts.isBindingElement(parent) && (parent.name === node || parent.propertyName === node);
45
+ }
46
+
47
+ function globalName(node: ts.Node, checker: ts.TypeChecker): string | undefined {
48
+ if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
49
+ return qualifiedName(node, checker);
50
+ }
51
+ if (!ts.isIdentifier(node)) {
52
+ return undefined;
53
+ }
54
+ const parent = node.parent;
55
+ if (bindingName(node) || ts.isLabeledStatement(parent) || ts.isBreakStatement(parent) || ts.isContinueStatement(parent)) {
56
+ return undefined;
57
+ }
58
+ if ((ts.isPropertyAccessExpression(parent) && parent.name === node) || (ts.isPropertyAssignment(parent) && parent.name === node)) {
59
+ return undefined;
60
+ }
61
+ if (ts.isShorthandPropertyAssignment(parent)) {
62
+ return checker.getShorthandAssignmentValueSymbol(parent) === undefined ? node.text : undefined;
63
+ }
64
+ return unbound(checker, node) ? node.text : undefined;
65
+ }
66
+
67
+ function propertyName(node: ts.Node): readonly string[] {
68
+ if (ts.isComputedPropertyName(node) && ts.isStringLiteral(node.expression)) {
69
+ return [node.expression.text];
70
+ }
71
+ return ts.isIdentifier(node) || ts.isStringLiteral(node) ? [node.text] : [];
72
+ }
73
+
74
+ function destructuringOf(
75
+ node: ts.Node,
76
+ ): { readonly pattern: ts.ObjectBindingPattern | ts.ObjectLiteralExpression; readonly root: ts.Expression } | undefined {
77
+ if ((ts.isVariableDeclaration(node) || ts.isParameter(node)) && ts.isObjectBindingPattern(node.name) && node.initializer !== undefined) {
78
+ return { pattern: node.name, root: node.initializer };
79
+ }
80
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isObjectLiteralExpression(node.left)) {
81
+ return { pattern: node.left, root: node.right };
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ function destructuredNames(node: ts.Node, checker: ts.TypeChecker): readonly string[] {
87
+ const destructuring = destructuringOf(node);
88
+ if (destructuring === undefined) {
89
+ return [];
90
+ }
91
+ const { pattern, root } = destructuring;
92
+ if (!globalThisRoot(root, checker)) {
93
+ return [];
94
+ }
95
+ if (ts.isObjectBindingPattern(pattern)) {
96
+ return pattern.elements.flatMap((element) => (element.dotDotDotToken === undefined ? propertyName(element.propertyName ?? element.name) : []));
97
+ }
98
+ return pattern.properties.flatMap((property) =>
99
+ ts.isShorthandPropertyAssignment(property) || ts.isPropertyAssignment(property) ? propertyName(property.name) : [],
100
+ );
101
+ }
102
+
103
+ function superclassesOf(node: ts.HeritageClause): readonly ts.Expression[] {
104
+ if (node.token !== ts.SyntaxKind.ExtendsKeyword || !(ts.isClassDeclaration(node.parent) || ts.isClassExpression(node.parent))) {
105
+ return [];
106
+ }
107
+ return node.types.map((base) => base.expression);
108
+ }
109
+
110
+ export function browserGlobals(file: SourceFile): readonly Finding[] {
111
+ const parsed = parse(file);
112
+ if (parsed === undefined) {
113
+ return [];
114
+ }
115
+ const source = parsed;
116
+ const checker = checkerOf(source);
117
+ const findings: Finding[] = [];
118
+ function visit(node: ts.Node): void {
119
+ if (ts.isHeritageClause(node)) {
120
+ for (const expression of superclassesOf(node)) {
121
+ visit(expression);
122
+ }
123
+ return;
124
+ }
125
+ if (ts.isTypeNode(node) || ts.isImportDeclaration(node) || ts.isExportSpecifier(node)) {
126
+ return;
127
+ }
128
+ const direct = globalName(node, checker);
129
+ const names = [...destructuredNames(node, checker), ...(direct === undefined ? [] : [direct])];
130
+ for (const name of names.filter((candidate) => browserNames.has(candidate))) {
131
+ findings.push({
132
+ file: file.path,
133
+ line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
134
+ message: `Shared code uses browser global "${name}". Move browser-dependent code into src/client/ or pass the value into shared code.`,
135
+ subject: name,
136
+ });
137
+ }
138
+ ts.forEachChild(node, visit);
139
+ }
140
+ visit(source);
141
+ return findings;
142
+ }
@@ -0,0 +1,80 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import ts from "typescript";
4
+
5
+ const portable = new Set([
6
+ "AbortController",
7
+ "AbortSignal",
8
+ "Blob",
9
+ "BroadcastChannel",
10
+ "ByteLengthQueuingStrategy",
11
+ "CompressionStream",
12
+ "CountQueuingStrategy",
13
+ "Crypto",
14
+ "CryptoKey",
15
+ "CustomEvent",
16
+ "DOMException",
17
+ "DecompressionStream",
18
+ "Event",
19
+ "EventTarget",
20
+ "File",
21
+ "FormData",
22
+ "Headers",
23
+ "MessageChannel",
24
+ "MessageEvent",
25
+ "MessagePort",
26
+ "Performance",
27
+ "PerformanceEntry",
28
+ "PerformanceMark",
29
+ "PerformanceMeasure",
30
+ "PerformanceObserver",
31
+ "PerformanceObserverEntryList",
32
+ "PerformanceResourceTiming",
33
+ "ReadableByteStreamController",
34
+ "ReadableStream",
35
+ "ReadableStreamBYOBReader",
36
+ "ReadableStreamBYOBRequest",
37
+ "ReadableStreamDefaultController",
38
+ "ReadableStreamDefaultReader",
39
+ "Request",
40
+ "Response",
41
+ "SubtleCrypto",
42
+ "TextDecoder",
43
+ "TextDecoderStream",
44
+ "TextEncoder",
45
+ "TextEncoderStream",
46
+ "TransformStream",
47
+ "TransformStreamDefaultController",
48
+ "URL",
49
+ "URLPattern",
50
+ "URLSearchParams",
51
+ "WebSocket",
52
+ "WritableStream",
53
+ "WritableStreamDefaultController",
54
+ "WritableStreamDefaultWriter",
55
+ "atob",
56
+ "btoa",
57
+ "clearInterval",
58
+ "clearTimeout",
59
+ "console",
60
+ "crypto",
61
+ "fetch",
62
+ "performance",
63
+ "queueMicrotask",
64
+ "setInterval",
65
+ "setTimeout",
66
+ "structuredClone",
67
+ ]);
68
+
69
+ function namesInDom(): readonly string[] {
70
+ const path = join(dirname(ts.getDefaultLibFilePath({})), "lib.dom.d.ts");
71
+ const source = ts.createSourceFile(path, readFileSync(path, "utf8"), ts.ScriptTarget.Latest, true);
72
+ return source.statements.flatMap((statement) => {
73
+ if (ts.isVariableStatement(statement)) {
74
+ return statement.declarationList.declarations.flatMap(({ name }) => (ts.isIdentifier(name) ? [name.text] : []));
75
+ }
76
+ return ts.isFunctionDeclaration(statement) && statement.name !== undefined ? [statement.name.text] : [];
77
+ });
78
+ }
79
+
80
+ export const browserNames: ReadonlySet<string> = new Set(namesInDom().filter((name) => !portable.has(name)));
@@ -0,0 +1,12 @@
1
+ import { importGraph } from "#imports/graph.ts";
2
+ import type { Findings, RuleInputs } from "#rule.ts";
3
+ import { discoverFences } from "./discover.ts";
4
+ import type { Fence } from "./model.ts";
5
+ import { compilePolicy } from "./policy.ts";
6
+
7
+ export async function checkFences(inputs: RuleInputs, configured: readonly Fence[]): Promise<Findings> {
8
+ const graph = await importGraph(inputs);
9
+ const fences = [...configured, ...(await discoverFences(inputs, graph.packages))];
10
+ const policy = compilePolicy(fences, { files: inputs.files, packages: graph.packages });
11
+ return policy.flatMap((compiled) => compiled.evaluate(graph));
12
+ }
@@ -0,0 +1,38 @@
1
+ import { Schema } from "effect";
2
+ import { decodeWith } from "#decoded.ts";
3
+ import type { Fence, Selector } from "./model.ts";
4
+
5
+ const SelectorInput: Schema.Codec<Selector> = Schema.suspend(() =>
6
+ Schema.Union([
7
+ Schema.Struct({ kind: Schema.Literal("anything") }),
8
+ Schema.Struct({ kind: Schema.Literal("workspace") }),
9
+ Schema.Struct({ kind: Schema.Literal("anyOf"), members: Schema.Array(SelectorInput) }),
10
+ Schema.Struct({ base: SelectorInput, excluded: Schema.Array(SelectorInput), kind: Schema.Literal("except") }),
11
+ Schema.Struct({ kind: Schema.Literals(["files", "folders"]), paths: Schema.Array(Schema.String) }),
12
+ Schema.Struct({ kind: Schema.Literals(["modules", "packages", "scopes"]), names: Schema.Array(Schema.String) }),
13
+ ]),
14
+ );
15
+
16
+ const ExampleStep = Schema.Union([Schema.String, Schema.Struct({ external: Schema.String })]);
17
+
18
+ const Chain = Schema.TupleWithRest(Schema.Tuple([Schema.String, ExampleStep]), [ExampleStep]);
19
+
20
+ const FenceInput: Schema.Codec<Fence> = Schema.Struct({
21
+ _tag: Schema.Literal("Fence"),
22
+ examples: Schema.Struct({ illegal: Chain, legal: Chain }),
23
+ from: SelectorInput,
24
+ name: Schema.String,
25
+ prohibition: Schema.Union([
26
+ Schema.Struct({ kind: Schema.Literals(["import", "reach"]), to: SelectorInput }),
27
+ Schema.Struct({ kind: Schema.Literal("vocabulary"), subjects: Schema.Array(Schema.String), unit: SelectorInput }),
28
+ ]),
29
+ rationale: Schema.String,
30
+ });
31
+
32
+ export const Declarations = Schema.Array(FenceInput);
33
+
34
+ const standard = Schema.toStandardSchemaV1(Declarations, { parseOptions: { errors: "all", onExcessProperty: "error" } });
35
+
36
+ export function decodeDeclarations(value: unknown) {
37
+ return decodeWith(standard, value);
38
+ }
@@ -0,0 +1,37 @@
1
+ import { createHash } from "node:crypto";
2
+ import { posix, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import type { WorkspacePackage } from "#imports/workspace.ts";
5
+ import type { RuleInputs } from "#rule.ts";
6
+ import { decodeDeclarations } from "./declarations.ts";
7
+ import type { Fence } from "./model.ts";
8
+
9
+ const BOUNDARY_FILE = "quality.fences.config.ts";
10
+
11
+ async function loadBoundary(inputs: RuleInputs, directory: string): Promise<readonly Fence[]> {
12
+ const file = posix.join(directory, BOUNDARY_FILE);
13
+ try {
14
+ const text = await inputs.readText(file);
15
+ if (text === undefined) {
16
+ return [];
17
+ }
18
+ const url = pathToFileURL(resolve(inputs.root, file));
19
+ url.searchParams.set("content", createHash("sha256").update(text).digest("hex"));
20
+ const loaded: unknown = await import(url.href);
21
+ if (typeof loaded !== "object" || loaded === null || !("default" in loaded)) {
22
+ throw new Error("export an array of fence() declarations as the default export");
23
+ }
24
+ const decoded = await decodeDeclarations(loaded.default);
25
+ if (decoded._tag === "Invalid") {
26
+ throw new Error(`export an array of fence() declarations:\n${decoded.issues.join("\n")}`);
27
+ }
28
+ return decoded.value;
29
+ } catch (cause) {
30
+ throw new Error(`cannot load boundary at ${file}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
31
+ }
32
+ }
33
+
34
+ export async function discoverFences(inputs: RuleInputs, packages: readonly WorkspacePackage[]): Promise<readonly Fence[]> {
35
+ const boundaries = await Promise.all([".", ...packages.map((owner) => owner.directory)].map((directory) => loadBoundary(inputs, directory)));
36
+ return boundaries.flat();
37
+ }
@@ -29,7 +29,7 @@ export interface ImportGraph {
29
29
  const QUERY = /\?.*$/u;
30
30
 
31
31
  const NO_MODULES =
32
- "the import graph covers no modules: the sources hold no TypeScript or JavaScript module. Point `sources` at the code, or turn the imports rules off.";
32
+ "the import graph covers no modules: the sources hold no TypeScript or JavaScript module. Point `sources` at the TypeScript or JavaScript code.";
33
33
 
34
34
  function relativeTarget(from: string, request: ImportRequest): string | undefined {
35
35
  const relative = request.kind === "path-reference" || request.specifier.startsWith("./") || request.specifier.startsWith("../");
@@ -77,7 +77,7 @@ async function build(inputs: RuleInputs): Promise<ImportGraph> {
77
77
 
78
78
  const graphs = new WeakMap<readonly SourceFile[], Promise<ImportGraph>>();
79
79
 
80
- // The three imports rules run on one scan, so they share the graph built from its sources.
80
+ // Import rules run on one scan, so they share the graph built from its sources.
81
81
  export function importGraph(inputs: RuleInputs): Promise<ImportGraph> {
82
82
  const known = graphs.get(inputs.sources) ?? build(inputs);
83
83
  graphs.set(inputs.sources, known);
@@ -0,0 +1,11 @@
1
+ import { posix } from "node:path";
2
+
3
+ const TOOL_FOLDERS = new Set(["script", "scripts", "tooling"]);
4
+
5
+ const CONFIG_FILE = /(?:^|\.)config\.[cm]?[jt]sx?$/u;
6
+
7
+ export function isTooling(directory: string, path: string): boolean {
8
+ const local = posix.relative(directory, path);
9
+ const [folder = ""] = local.split("/");
10
+ return TOOL_FOLDERS.has(folder) || (!local.includes("/") && CONFIG_FILE.test(local));
11
+ }
@@ -73,8 +73,8 @@ function nearestConfig(root: string, file: string): string | undefined {
73
73
  return undefined;
74
74
  }
75
75
 
76
- function memo<Value>(compute: (key: string) => Value): (key: string) => Value {
77
- const known = new Map<string, Value>();
76
+ function memo<TValue>(compute: (key: string) => TValue): (key: string) => TValue {
77
+ const known = new Map<string, TValue>();
78
78
  return (key) => {
79
79
  const value = known.get(key) ?? compute(key);
80
80
  known.set(key, value);
@@ -0,0 +1,63 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { join, posix } from "node:path";
3
+ import type { RuleInputs } from "#rule.ts";
4
+ import { isTooling } from "./isTooling.ts";
5
+ import { packageOf, type WorkspacePackage } from "./workspace.ts";
6
+
7
+ export type Runtime = "client" | "server" | "shared";
8
+
9
+ function executablePath(value: unknown): boolean {
10
+ return typeof value === "string" && value.trim().length > 0;
11
+ }
12
+
13
+ function hasExecutable(root: string, directory: string): boolean {
14
+ const path = join(root, directory, "package.json");
15
+ try {
16
+ const manifest: unknown = JSON.parse(readFileSync(path, "utf8"));
17
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
18
+ throw new Error("the manifest must be an object");
19
+ }
20
+ const bin: unknown = Reflect.get(manifest, "bin");
21
+ return executablePath(bin) || (typeof bin === "object" && bin !== null && !Array.isArray(bin) && Object.values(bin).some(executablePath));
22
+ } catch (cause) {
23
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") {
24
+ return false;
25
+ }
26
+ throw new Error(`cannot read runtime policy at ${path}`, { cause });
27
+ }
28
+ }
29
+
30
+ function directoryExists(path: string): boolean {
31
+ try {
32
+ return statSync(path).isDirectory();
33
+ } catch (cause) {
34
+ if (cause instanceof Error && "code" in cause && (cause.code === "ENOENT" || cause.code === "ENOTDIR")) {
35
+ return false;
36
+ }
37
+ throw cause;
38
+ }
39
+ }
40
+
41
+ export function runtimeOf(inputs: RuleInputs, packages: readonly WorkspacePackage[]): (path: string) => Runtime | undefined {
42
+ const owners = [...packages, { directory: ".", name: "." }];
43
+ const adopted = new Set(
44
+ owners
45
+ .filter((owner) => ["client", "server"].some((folder) => directoryExists(join(inputs.root, owner.directory, "src", folder))))
46
+ .map((owner) => owner.directory),
47
+ );
48
+ const executable = new Set(owners.filter((owner) => hasExecutable(inputs.root, owner.directory)).map((owner) => owner.directory));
49
+ return (path) => {
50
+ const owner = packageOf(packages, path)?.directory ?? ".";
51
+ if (isTooling(owner, path)) {
52
+ return "server";
53
+ }
54
+ if (!adopted.has(owner)) {
55
+ return executable.has(owner) ? "server" : undefined;
56
+ }
57
+ const local = posix.relative(owner, path);
58
+ if (local.startsWith("src/client/")) {
59
+ return "client";
60
+ }
61
+ return local.startsWith("src/server/") ? "server" : "shared";
62
+ };
63
+ }
@@ -1,4 +1,7 @@
1
1
  import { posix } from "node:path";
2
+ import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
3
+ import { Effect } from "effect";
4
+ import { manifestsIn } from "#manifests/sorted.ts";
2
5
  import type { RuleInputs } from "#rule.ts";
3
6
  import { globMatcher } from "./glob.ts";
4
7
  import { pnpmWorkspacePatterns } from "./pnpm-workspace.ts";
@@ -8,7 +11,7 @@ export interface WorkspacePackage {
8
11
  readonly name: string;
9
12
  }
10
13
 
11
- type Reader = Pick<RuleInputs, "files" | "readText">;
14
+ type Reader = Pick<RuleInputs, "files" | "readText"> & Partial<Pick<RuleInputs, "root">>;
12
15
 
13
16
  const MANIFEST = "package.json";
14
17
 
@@ -60,7 +63,9 @@ async function memberOf(inputs: Reader): Promise<(directory: string) => boolean>
60
63
 
61
64
  export async function workspacePackages(inputs: Reader): Promise<readonly WorkspacePackage[]> {
62
65
  const member = await memberOf(inputs);
63
- const directories = inputs.files
66
+ const manifests =
67
+ inputs.root === undefined ? inputs.files : await Effect.runPromise(manifestsIn(inputs.root).pipe(Effect.provide(NodeFileSystem.layer)));
68
+ const directories = manifests
64
69
  .filter((file) => posix.basename(file) === MANIFEST && file !== MANIFEST)
65
70
  .map((manifest) => posix.dirname(manifest))
66
71
  .filter(member);
@@ -46,7 +46,14 @@ export const collectInventory = (root: string, scope: InventoryScope): Effect.Ef
46
46
  const excluded = ignore().add([...scope.exclude]);
47
47
  const zones = yield* Effect.forEach(scope.sources, (zone) => zoneFiles(root, zone), { concurrency: READ_CONCURRENCY });
48
48
  const relativePaths = zones.flat().map((absolute) => posix(relative(root, absolute)));
49
- const files = [...new Set(relativePaths)].filter((path) => !excluded.ignores(path)).sort();
49
+ const files = [...new Set(relativePaths)]
50
+ .filter((path) => !excluded.ignores(path))
51
+ .sort((left, right) => {
52
+ if (left === right) {
53
+ return 0;
54
+ }
55
+ return left < right ? -1 : 1;
56
+ });
50
57
  const sourcePaths = files.filter((path) => scope.extensions.some((extension) => path.endsWith(extension)));
51
58
  const sources = yield* Effect.forEach(sourcePaths, (path) => readSource(root, path), { concurrency: READ_CONCURRENCY });
52
59
  return { files, sources };
@@ -0,0 +1,28 @@
1
+ import { execFile } from "node:child_process";
2
+ import { join } from "node:path";
3
+ import { promisify } from "node:util";
4
+
5
+ const run = promisify(execFile);
6
+
7
+ async function listedPaths(directory: string, options: readonly string[]): Promise<readonly string[]> {
8
+ try {
9
+ const { stdout } = await run("git", ["ls-files", "-z", ...options, "--", "."], { cwd: directory, maxBuffer: 2 ** 30 });
10
+ return stdout
11
+ .split("\0")
12
+ .filter(Boolean)
13
+ .map((path) => join(directory, path));
14
+ } catch (error) {
15
+ if (error instanceof Error && "stderr" in error && String(error.stderr).includes("not a git repository")) {
16
+ return [];
17
+ }
18
+ throw error;
19
+ }
20
+ }
21
+
22
+ export function trackedPaths(directory: string): Promise<readonly string[]> {
23
+ return listedPaths(directory, []);
24
+ }
25
+
26
+ export function ignoredTrackedPaths(directory: string): Promise<readonly string[]> {
27
+ return listedPaths(directory, ["--cached", "--ignored", "--exclude-standard"]);
28
+ }
@@ -1,20 +1,22 @@
1
- import { join, relative, sep } from "node:path";
1
+ import { dirname, join, relative, sep } from "node:path";
2
2
  import { Effect, FileSystem } from "effect";
3
- import { type FilesystemFailure, orWhenAbsent, readOptionalText } from "./filesystem.ts";
3
+ import { FilesystemFailure, orWhenAbsent, readOptionalText } from "./filesystem.ts";
4
4
  import { emptyScope, type IgnoreScope, insideKept, verdictFor, withIgnoreFile } from "./ignore-scope.ts";
5
+ import { trackedPaths } from "./trackedPaths.ts";
5
6
 
6
7
  const SKIPPED = new Set([".git", "node_modules"]);
7
8
 
8
- export const ignoreScopeAt = (
9
+ export function ignoreScopeAt(
9
10
  dir: string,
10
11
  inherited: IgnoreScope = emptyScope,
11
- ): Effect.Effect<IgnoreScope, FilesystemFailure, FileSystem.FileSystem> =>
12
- Effect.map(readOptionalText(join(dir, ".gitignore")), (contents) =>
12
+ ): Effect.Effect<IgnoreScope, FilesystemFailure, FileSystem.FileSystem> {
13
+ return Effect.map(readOptionalText(join(dir, ".gitignore")), (contents) =>
13
14
  contents === undefined || contents === "" ? inherited : withIgnoreFile(inherited, dir, contents),
14
15
  );
16
+ }
15
17
 
16
18
  // The walk itself never visits the directories between the root and a source, so their ignore files are read here.
17
- export const scopeAbove = (root: string, dir: string): Effect.Effect<IgnoreScope, FilesystemFailure, FileSystem.FileSystem> => {
19
+ export function scopeAbove(root: string, dir: string): Effect.Effect<IgnoreScope, FilesystemFailure, FileSystem.FileSystem> {
18
20
  const parts = relative(root, dir)
19
21
  .split(sep)
20
22
  .filter((part) => part !== "");
@@ -22,37 +24,64 @@ export const scopeAbove = (root: string, dir: string): Effect.Effect<IgnoreScope
22
24
  return Effect.reduce(
23
25
  ancestors,
24
26
  () => emptyScope,
25
- (scope, ancestor) => ignoreScopeAt(ancestor, scope),
27
+ (scope, ancestor) => (verdictFor(scope, ancestor, true) === "ignored" ? Effect.succeed(scope) : ignoreScopeAt(ancestor, scope)),
26
28
  );
27
- };
29
+ }
28
30
 
29
- export const walk = (dir: string, inherited: IgnoreScope = emptyScope): Effect.Effect<readonly string[], FilesystemFailure, FileSystem.FileSystem> =>
30
- Effect.gen(function* () {
31
- const fs = yield* FileSystem.FileSystem;
32
- const scope = yield* ignoreScopeAt(dir, inherited);
33
- const entries = yield* orWhenAbsent<readonly string[]>(dir, fs.readDirectory(dir), []);
34
- const nested = yield* Effect.forEach(
35
- entries.filter((entry) => !SKIPPED.has(entry)),
36
- (entry) => walkEntry(join(dir, entry), scope),
37
- { concurrency: 1 },
38
- );
39
- return nested.flat();
31
+ export const walk = Effect.fnUntraced(function* (
32
+ dir: string,
33
+ inherited: IgnoreScope = emptyScope,
34
+ ): Effect.fn.Return<readonly string[], FilesystemFailure, FileSystem.FileSystem> {
35
+ const fs = yield* FileSystem.FileSystem;
36
+ if ((yield* orWhenAbsent<FileSystem.File.Info | undefined>(dir, fs.stat(dir), undefined)) === undefined) {
37
+ return [];
38
+ }
39
+ const paths = yield* Effect.tryPromise({
40
+ catch: (cause) => new FilesystemFailure({ message: `cannot list tracked files in ${dir}: ${String(cause)}`, path: dir }),
41
+ try: () => trackedPaths(dir),
40
42
  });
41
-
42
- const walkEntry = (path: string, scope: IgnoreScope): Effect.Effect<readonly string[], FilesystemFailure, FileSystem.FileSystem> =>
43
- Effect.gen(function* () {
44
- const fs = yield* FileSystem.FileSystem;
45
- const info = yield* orWhenAbsent<FileSystem.File.Info | undefined>(path, fs.stat(path), undefined);
46
- if (info === undefined) {
47
- return [];
48
- }
49
- const directory = info.type === "Directory";
50
- const verdict = verdictFor(scope, path, directory);
51
- if (verdict === "ignored") {
52
- return [];
53
- }
54
- if (!directory) {
55
- return [path];
43
+ const tracked = new Set(paths);
44
+ for (const path of paths) {
45
+ for (let folder = dirname(path); folder !== dir && folder !== dirname(folder); folder = dirname(folder)) {
46
+ tracked.add(folder);
56
47
  }
57
- return yield* walk(path, verdict === "kept" ? insideKept(scope, path) : scope);
58
- });
48
+ }
49
+ return yield* walkDirectory(dir, inherited, tracked);
50
+ });
51
+
52
+ const walkDirectory = Effect.fnUntraced(function* (
53
+ dir: string,
54
+ inherited: IgnoreScope,
55
+ tracked: ReadonlySet<string>,
56
+ ): Effect.fn.Return<readonly string[], FilesystemFailure, FileSystem.FileSystem> {
57
+ const fs = yield* FileSystem.FileSystem;
58
+ const scope = verdictFor(inherited, dir, true) === "ignored" ? inherited : yield* ignoreScopeAt(dir, inherited);
59
+ const entries = yield* orWhenAbsent<readonly string[]>(dir, fs.readDirectory(dir), []);
60
+ const nested = yield* Effect.forEach(
61
+ entries.filter((entry) => !SKIPPED.has(entry)),
62
+ (entry) => walkEntry(join(dir, entry), scope, tracked),
63
+ { concurrency: 1 },
64
+ );
65
+ return nested.flat();
66
+ });
67
+
68
+ const walkEntry = Effect.fnUntraced(function* (
69
+ path: string,
70
+ scope: IgnoreScope,
71
+ tracked: ReadonlySet<string>,
72
+ ): Effect.fn.Return<readonly string[], FilesystemFailure, FileSystem.FileSystem> {
73
+ const fs = yield* FileSystem.FileSystem;
74
+ const info = yield* orWhenAbsent<FileSystem.File.Info | undefined>(path, fs.stat(path), undefined);
75
+ if (info === undefined) {
76
+ return [];
77
+ }
78
+ const directory = info.type === "Directory";
79
+ const verdict = verdictFor(scope, path, directory);
80
+ if (verdict === "ignored" && !tracked.has(path)) {
81
+ return [];
82
+ }
83
+ if (!directory) {
84
+ return [path];
85
+ }
86
+ return yield* walkDirectory(path, verdict === "kept" ? insideKept(scope, path) : scope, tracked);
87
+ });
@@ -11,7 +11,10 @@ import { otherNames } from "#rules/files/otherNames.ts";
11
11
  import { importsAliased } from "#rules/imports/aliased.ts";
12
12
  import { importCycles } from "#rules/imports/cycles.ts";
13
13
  import { importFences } from "#rules/imports/fences.ts";
14
+ import { importsManifest } from "#rules/imports/manifest.ts";
15
+ import { noTestCode } from "#rules/imports/noTestCode.ts";
14
16
  import { importsResolvable } from "#rules/imports/resolvable.ts";
17
+ import { importsRuntime } from "#rules/imports/runtime.ts";
15
18
  import { storySetup } from "#rules/stories/setup.ts";
16
19
  import { biomeOverrides } from "#rules/suppressions/biome-overrides.ts";
17
20
  import { biomeRecommended } from "#rules/suppressions/biomeRecommended.ts";
@@ -41,8 +44,11 @@ export const builtInRules = [
41
44
  biome,
42
45
  importCycles,
43
46
  importFences,
47
+ importsRuntime,
44
48
  importsResolvable,
49
+ importsManifest,
45
50
  importsAliased,
51
+ noTestCode,
46
52
  manifestsSorted,
47
53
  namedAfterExport,
48
54
  folderNames,
@@ -6,7 +6,7 @@ import { commentsOf, type SourceComment } from "./scan.ts";
6
6
 
7
7
  const MaxPerFileOptions = Schema.Struct({
8
8
  allow: Pragmas,
9
- max: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).pipe(Schema.withDecodingDefaultKey(Effect.succeed(2))),
9
+ max: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(2)).pipe(Schema.withDecodingDefaultKey(Effect.succeed(2))),
10
10
  });
11
11
 
12
12
  const continues = (previous: SourceComment | undefined, comment: SourceComment): boolean =>
@@ -2,7 +2,7 @@ import { definePatternRule } from "./pattern-rule.ts";
2
2
 
3
3
  export const noEnvironmentPragma = definePatternRule({
4
4
  description:
5
- "A test file gets its environment from its name, not from a pragma: `*.dom.test.ts` runs in a DOM, every other test in Node. `@shivaedev/quality/vitest.ts` sets up the Vitest projects that read the name.",
5
+ "A test file gets its environment from its name, not from a pragma: `*.dom.test.ts` runs in a DOM, every other test in Node. `@shivaedev/quality/testProjects.ts` sets up the Vitest projects that read the name.",
6
6
  id: "comments/no-environment-pragma",
7
7
  message: (match) => `Sets the test environment with "${match}". Name the file *.dom.test.ts or *.dom.test.tsx instead.`,
8
8
  patterns: [/^@(?:vitest|jest)-environment(?:-options)?(?=\s|$)/mu],