@shivaedev/quality 0.18.0 → 0.19.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 (203) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +143 -536
  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/hooks/preCommit.d.ts +2 -2
  58. package/dist/hooks/preCommit.d.ts.map +1 -1
  59. package/dist/hooks/preCommit.js +24 -34
  60. package/dist/hooks/preCommit.js.map +1 -1
  61. package/dist/imports/browserGlobals.d.ts +3 -0
  62. package/dist/imports/browserGlobals.d.ts.map +1 -0
  63. package/dist/imports/browserGlobals.js +125 -0
  64. package/dist/imports/browserGlobals.js.map +1 -0
  65. package/dist/imports/browserNames.d.ts +2 -0
  66. package/dist/imports/browserNames.d.ts.map +1 -0
  67. package/dist/imports/browserNames.js +78 -0
  68. package/dist/imports/browserNames.js.map +1 -0
  69. package/dist/imports/fences/check.d.ts +4 -0
  70. package/dist/imports/fences/check.d.ts.map +1 -0
  71. package/dist/imports/fences/check.js +10 -0
  72. package/dist/imports/fences/check.js.map +1 -0
  73. package/dist/imports/fences/declarations.d.ts +5 -0
  74. package/dist/imports/fences/declarations.d.ts.map +1 -0
  75. package/dist/imports/fences/declarations.js +29 -0
  76. package/dist/imports/fences/declarations.js.map +1 -0
  77. package/dist/imports/fences/discover.d.ts +5 -0
  78. package/dist/imports/fences/discover.d.ts.map +1 -0
  79. package/dist/imports/fences/discover.js +41 -0
  80. package/dist/imports/fences/discover.js.map +1 -0
  81. package/dist/imports/graph.js +2 -2
  82. package/dist/imports/graph.js.map +1 -1
  83. package/dist/imports/projects.js.map +1 -1
  84. package/dist/imports/runtime.d.ts +5 -0
  85. package/dist/imports/runtime.d.ts.map +1 -0
  86. package/dist/imports/runtime.js +32 -0
  87. package/dist/imports/runtime.js.map +1 -0
  88. package/dist/imports/workspace.d.ts +1 -1
  89. package/dist/imports/workspace.d.ts.map +1 -1
  90. package/dist/imports/workspace.js +5 -1
  91. package/dist/imports/workspace.js.map +1 -1
  92. package/dist/inventory/collect.d.ts.map +1 -1
  93. package/dist/inventory/collect.js +8 -1
  94. package/dist/inventory/collect.js.map +1 -1
  95. package/dist/inventory/trackedPaths.d.ts +3 -0
  96. package/dist/inventory/trackedPaths.d.ts.map +1 -0
  97. package/dist/inventory/trackedPaths.js +26 -0
  98. package/dist/inventory/trackedPaths.js.map +1 -0
  99. package/dist/inventory/walk.d.ts +4 -4
  100. package/dist/inventory/walk.d.ts.map +1 -1
  101. package/dist/inventory/walk.js +32 -12
  102. package/dist/inventory/walk.js.map +1 -1
  103. package/dist/rules/built-in.d.ts +2 -3
  104. package/dist/rules/built-in.d.ts.map +1 -1
  105. package/dist/rules/built-in.js +6 -0
  106. package/dist/rules/built-in.js.map +1 -1
  107. package/dist/rules/comments/max-per-file.js +1 -1
  108. package/dist/rules/comments/max-per-file.js.map +1 -1
  109. package/dist/rules/files/folderNames.js +1 -1
  110. package/dist/rules/files/folderNames.js.map +1 -1
  111. package/dist/rules/files/otherNames.d.ts.map +1 -1
  112. package/dist/rules/files/otherNames.js +1 -10
  113. package/dist/rules/files/otherNames.js.map +1 -1
  114. package/dist/rules/imports/bundleInputs.d.ts +7 -0
  115. package/dist/rules/imports/bundleInputs.d.ts.map +1 -0
  116. package/dist/rules/imports/bundleInputs.js +77 -0
  117. package/dist/rules/imports/bundleInputs.js.map +1 -0
  118. package/dist/rules/imports/fences.d.ts +1 -2
  119. package/dist/rules/imports/fences.d.ts.map +1 -1
  120. package/dist/rules/imports/fences.js +4 -14
  121. package/dist/rules/imports/fences.js.map +1 -1
  122. package/dist/rules/imports/manifest.d.ts +2 -0
  123. package/dist/rules/imports/manifest.d.ts.map +1 -0
  124. package/dist/rules/imports/manifest.js +98 -0
  125. package/dist/rules/imports/manifest.js.map +1 -0
  126. package/dist/rules/imports/noTestCode.d.ts +2 -0
  127. package/dist/rules/imports/noTestCode.d.ts.map +1 -0
  128. package/dist/rules/imports/noTestCode.js +17 -0
  129. package/dist/rules/imports/noTestCode.js.map +1 -0
  130. package/dist/rules/imports/runtime.d.ts +2 -0
  131. package/dist/rules/imports/runtime.d.ts.map +1 -0
  132. package/dist/rules/imports/runtime.js +41 -0
  133. package/dist/rules/imports/runtime.js.map +1 -0
  134. package/dist/rules/max-lines.d.ts +0 -1
  135. package/dist/rules/max-lines.d.ts.map +1 -1
  136. package/dist/rules/max-lines.js +3 -10
  137. package/dist/rules/max-lines.js.map +1 -1
  138. package/dist/rules/suppressions/biome-overrides.d.ts.map +1 -1
  139. package/dist/rules/suppressions/biome-overrides.js +21 -4
  140. package/dist/rules/suppressions/biome-overrides.js.map +1 -1
  141. package/dist/rules/suppressions/biomeRecommended.js +2 -2
  142. package/dist/rules/suppressions/biomeRecommended.js.map +1 -1
  143. package/dist/rules/suppressions/no-inline.js +1 -1
  144. package/dist/rules/suppressions/no-inline.js.map +1 -1
  145. package/dist/standard-schema.d.ts +1 -1
  146. package/dist/standard-schema.d.ts.map +1 -1
  147. package/dist/standard-schema.js +10 -2
  148. package/dist/standard-schema.js.map +1 -1
  149. package/dist/vitest.d.ts +1 -3
  150. package/dist/vitest.d.ts.map +1 -1
  151. package/dist/vitest.js +11 -6
  152. package/dist/vitest.js.map +1 -1
  153. package/docs/debt-and-hooks.md +90 -0
  154. package/docs/modules.md +253 -0
  155. package/docs/naming.md +126 -0
  156. package/docs/north-star.md +61 -0
  157. package/docs/roadmap.md +37 -0
  158. package/docs/rules.md +308 -0
  159. package/docs/tooling.md +120 -0
  160. package/package.json +7 -5
  161. package/src/biome/nativeEffectCall.ts +45 -0
  162. package/src/biome/recognizeDiagnostics.ts +67 -0
  163. package/src/biome/report.ts +24 -6
  164. package/src/biome/syntax.ts +36 -0
  165. package/src/cli/args.ts +2 -2
  166. package/src/cli/baseline.ts +3 -3
  167. package/src/cli/fix.ts +13 -13
  168. package/src/cli/run-main.ts +1 -1
  169. package/src/config/decode.ts +7 -3
  170. package/src/config/load.ts +15 -9
  171. package/src/config/resolve.ts +10 -15
  172. package/src/config.ts +8 -15
  173. package/src/decoded.ts +3 -3
  174. package/src/engine/session.ts +3 -3
  175. package/src/engine/violation.ts +2 -2
  176. package/src/hooks/preCommit.ts +30 -39
  177. package/src/imports/browserGlobals.ts +142 -0
  178. package/src/imports/browserNames.ts +80 -0
  179. package/src/imports/fences/check.ts +12 -0
  180. package/src/imports/fences/declarations.ts +38 -0
  181. package/src/imports/fences/discover.ts +37 -0
  182. package/src/imports/graph.ts +2 -2
  183. package/src/imports/projects.ts +2 -2
  184. package/src/imports/runtime.ts +37 -0
  185. package/src/imports/workspace.ts +7 -2
  186. package/src/inventory/collect.ts +8 -1
  187. package/src/inventory/trackedPaths.ts +28 -0
  188. package/src/inventory/walk.ts +65 -36
  189. package/src/rules/built-in.ts +6 -0
  190. package/src/rules/comments/max-per-file.ts +1 -1
  191. package/src/rules/files/folderNames.ts +1 -1
  192. package/src/rules/files/otherNames.ts +1 -10
  193. package/src/rules/imports/bundleInputs.ts +94 -0
  194. package/src/rules/imports/fences.ts +4 -16
  195. package/src/rules/imports/manifest.ts +116 -0
  196. package/src/rules/imports/noTestCode.ts +18 -0
  197. package/src/rules/imports/runtime.ts +43 -0
  198. package/src/rules/max-lines.ts +4 -10
  199. package/src/rules/suppressions/biome-overrides.ts +31 -4
  200. package/src/rules/suppressions/biomeRecommended.ts +2 -2
  201. package/src/rules/suppressions/no-inline.ts +1 -1
  202. package/src/standard-schema.ts +11 -3
  203. package/src/vitest.ts +11 -6
@@ -1,11 +1,14 @@
1
+ import { relative, sep } from "node:path";
1
2
  import { Schema } from "effect";
3
+ import { ignoredTrackedPaths } from "#inventory/trackedPaths.ts";
4
+ import { recognizeDiagnostics } from "./recognizeDiagnostics.ts";
2
5
  import { type BiomeRun, runBiome } from "./run.ts";
3
6
 
4
- const Position = Schema.Struct({ line: Schema.Int });
7
+ const Position = Schema.Struct({ column: Schema.optionalKey(Schema.Int), line: Schema.Int });
5
8
 
6
9
  const Diagnostic = Schema.Struct({
7
10
  category: Schema.optionalKey(Schema.String),
8
- location: Schema.Struct({ path: Schema.optionalKey(Schema.String), start: Schema.optionalKey(Position) }),
11
+ location: Schema.Struct({ end: Schema.optionalKey(Position), path: Schema.optionalKey(Schema.String), start: Schema.optionalKey(Position) }),
9
12
  message: Schema.String,
10
13
  severity: Schema.String,
11
14
  });
@@ -21,11 +24,12 @@ export type Report = typeof Report.Type;
21
24
 
22
25
  const decodeReport = Schema.decodeUnknownOption(Schema.fromJsonString(Report));
23
26
 
24
- const failure = (args: readonly string[], run: BiomeRun): Error =>
25
- new Error(`biome ${args.join(" ")} exited ${run.code} without a report:\n${[run.stdout, run.stderr].join("\n").trim()}`);
27
+ function failure(args: readonly string[], run: BiomeRun): Error {
28
+ return new Error(`biome ${args.join(" ")} exited ${run.code} without a report:\n${[run.stdout, run.stderr].join("\n").trim()}`);
29
+ }
26
30
 
27
- export async function biomeReport(root: string, args: readonly string[]): Promise<Report> {
28
- const command = [...args, "--reporter=json", "--max-diagnostics=none", "."];
31
+ async function reportFor(root: string, args: readonly string[], paths: readonly string[]): Promise<Report> {
32
+ const command = [...args, "--reporter=json", "--max-diagnostics=none", ...paths];
29
33
  const run = await runBiome(root, command);
30
34
  const report = decodeReport(run.stdout.trim());
31
35
  if (report._tag === "None") {
@@ -33,3 +37,17 @@ export async function biomeReport(root: string, args: readonly string[]): Promis
33
37
  }
34
38
  return report.value;
35
39
  }
40
+
41
+ export async function biomeReport(root: string, args: readonly string[]): Promise<Report> {
42
+ const [report, tracked] = await Promise.all([reportFor(root, args, ["."]), ignoredTrackedPaths(root)]);
43
+ const paths = tracked
44
+ .map((path) => relative(root, path))
45
+ .filter((path) => !path.split(sep).some((part) => part === ".git" || part === "node_modules"));
46
+ const extra = paths.length === 0 ? undefined : await reportFor(root, [...args, "--vcs-enabled=false"], paths);
47
+ const diagnostics = [...report.diagnostics, ...(extra?.diagnostics ?? [])];
48
+ const unique = [...new Map(diagnostics.map((diagnostic) => [JSON.stringify(diagnostic), diagnostic])).values()];
49
+ return {
50
+ diagnostics: recognizeDiagnostics(root, unique),
51
+ summary: { changed: report.summary.changed + (extra?.summary.changed ?? 0) },
52
+ };
53
+ }
@@ -0,0 +1,36 @@
1
+ import { readFileSync } from "node:fs";
2
+ import ts from "typescript";
3
+
4
+ export interface Syntax {
5
+ readonly checker: ts.TypeChecker;
6
+ readonly source: ts.SourceFile;
7
+ }
8
+
9
+ export function syntaxOf(path: string): Syntax {
10
+ const source = ts.createSourceFile(path, readFileSync(path, "utf8"), ts.ScriptTarget.Latest, true);
11
+ const options: ts.CompilerOptions = { allowJs: true, noLib: true, noResolve: true };
12
+ const host = ts.createCompilerHost(options);
13
+ host.getSourceFile = (file) => (file === path ? source : undefined);
14
+ const program = ts.createProgram([path], options, host);
15
+ return { checker: program.getTypeChecker(), source };
16
+ }
17
+
18
+ export function offsetOf(source: ts.SourceFile, position: { readonly line: number; readonly column?: number }): number | undefined {
19
+ const start = source.getLineStarts()[position.line - 1];
20
+ if (start === undefined || position.column === undefined || position.column < 1) {
21
+ return undefined;
22
+ }
23
+ const line = source.text.slice(start, source.getLineStarts()[position.line] ?? source.text.length);
24
+ const characters = [...line];
25
+ return position.column <= characters.length + 1 ? start + characters.slice(0, position.column - 1).join("").length : undefined;
26
+ }
27
+
28
+ export function nodeAt(source: ts.SourceFile, start: number, end: number): ts.Node | undefined {
29
+ function visit(node: ts.Node): ts.Node | undefined {
30
+ if (node.getStart(source) > start || node.end < end) {
31
+ return undefined;
32
+ }
33
+ return ts.forEachChild(node, visit) ?? (node.getStart(source) === start && node.end === end ? node : undefined);
34
+ }
35
+ return visit(source);
36
+ }
package/src/cli/args.ts CHANGED
@@ -37,9 +37,9 @@ baseline migrate Move a baseline from the earlier JSON format (--from, quality/
37
37
  hooks install Install the git pre-commit hook, shared by every worktree, that runs hooks pre-commit. Keeps a hook that is not quality's
38
38
  unless --force replaces it.
39
39
  hooks uninstall Remove quality's pre-commit hook. Keeps a hook that is not quality's.
40
- hooks pre-commit What the hook runs: baseline tighten for staged files when preCommit.tighten is set, lint, then each preCommit.run command.
40
+ hooks pre-commit What the hook runs: lint, then package.json scripts typecheck and quality:commit when present.
41
41
 
42
- --config <file> Config file; its directory is the repository root. Defaults to ./quality.config.ts.
42
+ --config <file> Config file; its directory is the repository root. Without this option, uses ./quality.config.ts when present, otherwise defaults.
43
43
  --against <ref> The branch the work merges into, whose merge base prune follows moves from. Defaults to origin/HEAD, then origin/main, then origin/master.
44
44
  Exit codes: 0 passed, 1 failed the gate, 2 could not run.`;
45
45
 
@@ -46,11 +46,11 @@ const changes = (pruned: Pruned): string => {
46
46
  return `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
47
47
  };
48
48
 
49
- export const shrinkBaseline = <Requirements>(
49
+ export const shrinkBaseline = <TRequirements>(
50
50
  cwd: string,
51
51
  config: string | undefined,
52
- scopeOf: (session: Session) => Effect.Effect<Scope, SetupFailure, Requirements>,
53
- ): Effect.Effect<void, SetupFailure, FileSystem.FileSystem | Requirements> =>
52
+ scopeOf: (session: Session) => Effect.Effect<Scope, SetupFailure, TRequirements>,
53
+ ): Effect.Effect<void, SetupFailure, FileSystem.FileSystem | TRequirements> =>
54
54
  Effect.gen(function* () {
55
55
  const session = yield* openSession(cwd, config);
56
56
  if (session.baseline.raw === undefined) {
package/src/cli/fix.ts CHANGED
@@ -33,21 +33,21 @@ export function snapshotOf(root: string): Effect.Effect<ReadonlyMap<string, stri
33
33
  }).pipe(Effect.mapError((failure) => new SetupFailure({ message: failure.message })));
34
34
  }
35
35
 
36
- export interface Pass<Failure, Requirements> {
36
+ export interface Pass<TFailure, TRequirements> {
37
37
  readonly label: string;
38
- readonly run: Effect.Effect<number, Failure, Requirements>;
38
+ readonly run: Effect.Effect<number, TFailure, TRequirements>;
39
39
  }
40
40
 
41
- export interface Settling<Failure, Requirements> {
42
- readonly passes: ReadonlyArray<Pass<Failure, Requirements>>;
43
- readonly snapshot: Effect.Effect<ReadonlyMap<string, string>, Failure, Requirements>;
41
+ export interface Settling<TFailure, TRequirements> {
42
+ readonly passes: readonly Pass<TFailure, TRequirements>[];
43
+ readonly snapshot: Effect.Effect<ReadonlyMap<string, string>, TFailure, TRequirements>;
44
44
  }
45
45
 
46
46
  function changedBy(root: string, args: readonly string[]): Effect.Effect<number, SetupFailure> {
47
47
  return Effect.map(biome(root, args), (report) => report.summary.changed);
48
48
  }
49
49
 
50
- function biomePasses(root: string): ReadonlyArray<Pass<SetupFailure, never>> {
50
+ function biomePasses(root: string): readonly Pass<SetupFailure, never>[] {
51
51
  return [
52
52
  {
53
53
  label: "with fixes",
@@ -57,11 +57,11 @@ function biomePasses(root: string): ReadonlyArray<Pass<SetupFailure, never>> {
57
57
  ];
58
58
  }
59
59
 
60
- function round<Failure, Requirements>(
61
- passes: ReadonlyArray<Pass<Failure, Requirements>>,
60
+ function round<TFailure, TRequirements>(
61
+ passes: readonly Pass<TFailure, TRequirements>[],
62
62
  index: number,
63
- afterEachPass: Effect.Effect<void, Failure, Requirements>,
64
- ): Effect.Effect<number, Failure, Requirements> {
63
+ afterEachPass: Effect.Effect<void, TFailure, TRequirements>,
64
+ ): Effect.Effect<number, TFailure, TRequirements> {
65
65
  return Effect.gen(function* () {
66
66
  let total = 0;
67
67
  const parts: string[] = [];
@@ -91,14 +91,14 @@ function unsettled(changed: ReadonlySet<string>): SetupFailure {
91
91
  "Run `biome check --write --unsafe --verbose` to name them.",
92
92
  ];
93
93
  return new SetupFailure({
94
- message: [...named, "Two fixes probably undo each other there. Fix the code by hand, or turn off one of the rules."].join("\n"),
94
+ message: [...named, "Two fixes probably undo each other there. Repair the conflicting code by hand so both checks pass."].join("\n"),
95
95
  });
96
96
  }
97
97
 
98
- export function settle<Failure, Requirements>({
98
+ export function settle<TFailure, TRequirements>({
99
99
  passes,
100
100
  snapshot,
101
- }: Settling<Failure, Requirements>): Effect.Effect<void, Failure | SetupFailure, Requirements> {
101
+ }: Settling<TFailure, TRequirements>): Effect.Effect<void, TFailure | SetupFailure, TRequirements> {
102
102
  return Effect.gen(function* () {
103
103
  for (let index = 1; index < MAX_FIX_ROUNDS; index += 1) {
104
104
  if ((yield* round(passes, index, Effect.void)) === 0) {
@@ -47,7 +47,7 @@ export function quietOnClosedPipe(stream: EventEmitter): void {
47
47
 
48
48
  const services = NodeChildProcessSpawner.layer.pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)));
49
49
 
50
- export const runMain = <Value, Error>(program: Effect.Effect<Value, Error, FileSystem.FileSystem | Git>): void => {
50
+ export const runMain = <TValue, TError>(program: Effect.Effect<TValue, TError, FileSystem.FileSystem | Git>): void => {
51
51
  quietOnClosedPipe(process.stdout);
52
52
  quietOnClosedPipe(process.stderr);
53
53
  const explained = Effect.tapCause(program, (cause) => (Cause.hasInterruptsOnly(cause) ? Effect.void : explain(cause)));
@@ -12,7 +12,7 @@ const isRule = (value: unknown): value is Rule =>
12
12
  && "configure" in value
13
13
  && typeof value.configure === "function";
14
14
 
15
- const Level = Schema.Literals(["error", "warn", "off"]);
15
+ const Level = Schema.Literal("error");
16
16
 
17
17
  const Setting = Schema.Union([Level, Schema.Struct({ level: Schema.optionalKey(Level), options: Schema.optionalKey(Schema.Unknown) })]);
18
18
 
@@ -22,13 +22,17 @@ const AdoptRemoved = Schema.Never.annotate({
22
22
  message: "removed in quality 0.7.0. Delete the key, and record a rule's existing findings with `quality baseline write --rule <id>`.",
23
23
  });
24
24
 
25
+ const PreCommitRemoved = Schema.Never.annotate({
26
+ message:
27
+ "removed. Put repository checks in the package.json quality:commit script; the hook also runs typecheck when present. Use `quality baseline tighten --staged` explicitly to lower staged debt.",
28
+ });
29
+
25
30
  const ConfigInput = Schema.Struct({
26
31
  adopt: Schema.optionalKey(AdoptRemoved),
27
32
  baseline: Schema.optionalKey(Schema.String),
28
- exclude: Schema.optionalKey(Schema.Array(Schema.String)),
29
33
  extensions: Schema.optionalKey(Schema.Array(Schema.String)),
30
34
  local: Schema.optionalKey(Schema.Array(LocalRule)),
31
- preCommit: Schema.optionalKey(Schema.Struct({ run: Schema.optionalKey(Schema.Array(Schema.String)), tighten: Schema.optionalKey(Schema.Boolean) })),
35
+ preCommit: Schema.optionalKey(PreCommitRemoved),
32
36
  registry: Schema.optionalKey(Schema.String),
33
37
  rules: Schema.optionalKey(Schema.Record(Schema.String, Setting)),
34
38
  sources: Schema.optionalKey(Schema.Array(Schema.String)),
@@ -1,7 +1,6 @@
1
- import { dirname, resolve } from "node:path";
1
+ import { basename, dirname, resolve } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { Effect, FileSystem } from "effect";
4
- import type { PreCommit } from "#config.ts";
5
4
  import { validOrFail } from "#decoded.ts";
6
5
  import { SetupFailure } from "#failure.ts";
7
6
  import type { InventoryScope } from "#inventory/collect.ts";
@@ -14,7 +13,6 @@ const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs"
14
13
  export interface ResolvedConfig extends ResolvedRules, InventoryScope {
15
14
  readonly baseline: string;
16
15
  readonly file: string;
17
- readonly preCommit: Required<PreCommit>;
18
16
  readonly registry: string;
19
17
  readonly root: string;
20
18
  }
@@ -36,10 +34,9 @@ const importDefault = (file: string): Effect.Effect<unknown, SetupFailure> =>
36
34
  const resolved = (root: string, file: string, config: ConfigInput, rules: ResolvedRules): ResolvedConfig => ({
37
35
  ...rules,
38
36
  baseline: config.baseline ?? "quality/baseline.jsonl",
39
- exclude: config.exclude ?? [],
37
+ exclude: [],
40
38
  extensions: config.extensions ?? DEFAULT_EXTENSIONS,
41
39
  file,
42
- preCommit: { run: config.preCommit?.run ?? [], tighten: config.preCommit?.tighten ?? false },
43
40
  registry: config.registry ?? "quality/registry.json",
44
41
  root,
45
42
  sources: config.sources ?? ["."],
@@ -49,11 +46,20 @@ export const loadConfig = (cwd: string, path: string | undefined): Effect.Effect
49
46
  Effect.gen(function* () {
50
47
  const fs = yield* FileSystem.FileSystem;
51
48
  const file = resolve(cwd, path ?? CONFIG_FILE);
52
- const present = yield* Effect.orElseSucceed(fs.exists(file), () => false);
53
- if (!present) {
54
- return yield* new SetupFailure({ message: `no config at ${file}. Create ${CONFIG_FILE} exporting defineConfig({ ... }), or pass --config.` });
49
+ const entries = yield* fs
50
+ .readDirectory(dirname(file))
51
+ .pipe(
52
+ Effect.catchTag("PlatformError", (error) =>
53
+ error.reason._tag === "NotFound"
54
+ ? Effect.succeed<string[]>([])
55
+ : Effect.fail(new SetupFailure({ message: `cannot inspect ${file}: ${error.message}` })),
56
+ ),
57
+ );
58
+ const present = entries.includes(basename(file));
59
+ if (!present && path !== undefined) {
60
+ return yield* new SetupFailure({ message: `no config at ${file}. Check the --config path.` });
55
61
  }
56
- const loaded = yield* importDefault(file);
62
+ const loaded = present ? yield* importDefault(file) : {};
57
63
  const config = yield* validOrFail(file, yield* Effect.promise(() => decodeConfig(loaded)));
58
64
  const rules = yield* validOrFail(file, yield* resolveRules(config));
59
65
  return resolved(dirname(file), file, config, rules);
@@ -1,5 +1,4 @@
1
1
  import { Effect } from "effect";
2
- import type { Level } from "#config.ts";
3
2
  import type { Decoded } from "#decoded.ts";
4
3
  import type { ActiveRule } from "#engine/run-rules.ts";
5
4
  import type { RuleIndex } from "#engine/violation.ts";
@@ -15,8 +14,6 @@ type Setting = NonNullable<ConfigInput["rules"]>[string];
15
14
 
16
15
  const messageOf = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause));
17
16
 
18
- const levelOf = (setting: Setting | undefined): Level => (typeof setting === "string" ? setting : (setting?.level ?? "error"));
19
-
20
17
  const optionsOf = (setting: Setting | undefined): unknown => (typeof setting === "object" ? setting.options : undefined);
21
18
 
22
19
  const duplicateIssues = (rules: readonly Rule[]): readonly string[] =>
@@ -24,23 +21,21 @@ const duplicateIssues = (rules: readonly Rule[]): readonly string[] =>
24
21
  rules.findIndex((other) => other.id === rule.id) === index ? [] : [`local: rule id "${rule.id}" is already defined`],
25
22
  );
26
23
 
27
- const activate = (rule: Rule, setting: Setting | undefined): Effect.Effect<Decoded<ActiveRule | undefined>> => {
28
- const level = levelOf(setting);
29
- if (level === "off") {
30
- return Effect.succeed({ _tag: "Valid", value: undefined });
31
- }
32
- return Effect.tryPromise({ catch: messageOf, try: () => rule.configure(optionsOf(setting)) }).pipe(
24
+ const activate = (rule: Rule, setting: Setting | undefined): Effect.Effect<Decoded<ActiveRule>> =>
25
+ Effect.tryPromise({ catch: messageOf, try: () => rule.configure(optionsOf(setting)) }).pipe(
33
26
  Effect.map(
34
- (configured): Decoded<ActiveRule | undefined> =>
27
+ (configured): Decoded<ActiveRule> =>
35
28
  configured._tag === "Invalid"
36
29
  ? { _tag: "Invalid", issues: configured.issues.map((issue) => `rules.${rule.id}.options: ${issue}`) }
37
- : { _tag: "Valid", value: { check: configured.check, description: rule.description, family: rule.family === true, id: rule.id, level } },
30
+ : {
31
+ _tag: "Valid",
32
+ value: { check: configured.check, description: rule.description, family: rule.family === true, id: rule.id, level: "error" },
33
+ },
38
34
  ),
39
35
  Effect.catch((failure: string) =>
40
- Effect.succeed<Decoded<ActiveRule | undefined>>({ _tag: "Invalid", issues: [`rules.${rule.id}.options: validation threw: ${failure}`] }),
36
+ Effect.succeed<Decoded<ActiveRule>>({ _tag: "Invalid", issues: [`rules.${rule.id}.options: validation threw: ${failure}`] }),
41
37
  ),
42
38
  );
43
- };
44
39
 
45
40
  export const resolveRules = (config: ConfigInput): Effect.Effect<Decoded<ResolvedRules>> =>
46
41
  Effect.gen(function* () {
@@ -58,8 +53,8 @@ export const resolveRules = (config: ConfigInput): Effect.Effect<Decoded<Resolve
58
53
  if (issues.length > 0) {
59
54
  return { _tag: "Invalid", issues };
60
55
  }
61
- const active = activated.flatMap((result) => (result._tag === "Valid" && result.value !== undefined ? [result.value] : []));
62
- const levels = new Map(rules.map((rule) => [rule.id, levelOf(settings[rule.id])]));
56
+ const active = activated.flatMap((result) => (result._tag === "Valid" ? [result.value] : []));
57
+ const levels = new Map(rules.map((rule) => [rule.id, "error" as const]));
63
58
  const unregistrable = new Set(rules.filter((rule) => rule.registrable === false).map((rule) => rule.id));
64
59
  return { _tag: "Valid", value: { active, families, levels, unregistrable } };
65
60
  });
package/src/config.ts CHANGED
@@ -3,30 +3,23 @@ import type { Rule } from "./rule.ts";
3
3
 
4
4
  export type Level = "error" | "warn" | "off";
5
5
 
6
- export type RuleSetting<Input> = Level | { readonly level?: Level; readonly options?: Input };
6
+ export type RuleSetting<TInput> = "error" | { readonly level?: "error"; readonly options?: TInput };
7
7
 
8
- type InputOf<Candidate> = Candidate extends Rule<string, infer Input> ? Input : never;
8
+ type InputOf<TCandidate> = TCandidate extends Rule<string, infer TInput> ? TInput : never;
9
9
 
10
- export type RuleSettings<Rules extends readonly Rule[]> = {
11
- readonly [Candidate in Rules[number] as Candidate["id"]]?: RuleSetting<InputOf<Candidate>>;
10
+ export type RuleSettings<TRules extends readonly Rule[]> = {
11
+ readonly [TCandidate in TRules[number] as TCandidate["id"]]?: RuleSetting<InputOf<TCandidate>>;
12
12
  };
13
13
 
14
14
  export type BuiltInRules = typeof builtInRules;
15
15
 
16
- export interface PreCommit {
17
- readonly run?: readonly string[];
18
- readonly tighten?: boolean;
19
- }
20
-
21
- export interface QualityConfig<Local extends readonly Rule[] = readonly Rule[]> {
16
+ export interface QualityConfig<TLocal extends readonly Rule[] = readonly Rule[]> {
22
17
  readonly baseline?: string;
23
- readonly exclude?: readonly string[];
24
18
  readonly extensions?: readonly string[];
25
- readonly local?: Local;
26
- readonly preCommit?: PreCommit;
19
+ readonly local?: TLocal;
27
20
  readonly registry?: string;
28
- readonly rules?: RuleSettings<readonly [...BuiltInRules, ...Local]>;
21
+ readonly rules?: RuleSettings<readonly [...BuiltInRules, ...TLocal]>;
29
22
  readonly sources?: readonly string[];
30
23
  }
31
24
 
32
- export const defineConfig = <const Local extends readonly Rule[] = readonly []>(config: QualityConfig<Local>): QualityConfig<Local> => config;
25
+ export const defineConfig = <const TLocal extends readonly Rule[] = readonly []>(config: QualityConfig<TLocal>): QualityConfig<TLocal> => config;
package/src/decoded.ts CHANGED
@@ -2,14 +2,14 @@ import { Effect } from "effect";
2
2
  import { SetupFailure } from "./failure.ts";
3
3
  import { describeIssue, type StandardSchemaV1 } from "./standard-schema.ts";
4
4
 
5
- export type Decoded<Value> = { readonly _tag: "Valid"; readonly value: Value } | { readonly _tag: "Invalid"; readonly issues: readonly string[] };
5
+ export type Decoded<TValue> = { readonly _tag: "Valid"; readonly value: TValue } | { readonly _tag: "Invalid"; readonly issues: readonly string[] };
6
6
 
7
- export const decodeWith = async <Output>(schema: StandardSchemaV1<unknown, Output>, value: unknown): Promise<Decoded<Output>> => {
7
+ export const decodeWith = async <TOutput>(schema: StandardSchemaV1<unknown, TOutput>, value: unknown): Promise<Decoded<TOutput>> => {
8
8
  const result = await schema["~standard"].validate(value);
9
9
  return result.issues === undefined ? { _tag: "Valid", value: result.value } : { _tag: "Invalid", issues: result.issues.map(describeIssue) };
10
10
  };
11
11
 
12
- export const validOrFail = <Value>(file: string, decoded: Decoded<Value>): Effect.Effect<Value, SetupFailure> =>
12
+ export const validOrFail = <TValue>(file: string, decoded: Decoded<TValue>): Effect.Effect<TValue, SetupFailure> =>
13
13
  decoded._tag === "Valid"
14
14
  ? Effect.succeed(decoded.value)
15
15
  : Effect.fail(new SetupFailure({ message: `${file} is invalid:\n${decoded.issues.map((issue) => ` - ${issue}`).join("\n")}` }));
@@ -18,9 +18,9 @@ export interface Session {
18
18
  readonly violations: readonly Violation[];
19
19
  }
20
20
 
21
- const setup = <Value, Requirements>(
22
- effect: Effect.Effect<Value, FilesystemFailure, Requirements>,
23
- ): Effect.Effect<Value, SetupFailure, Requirements> => Effect.mapError(effect, (failure) => new SetupFailure({ message: failure.message }));
21
+ const setup = <TValue, TRequirements>(
22
+ effect: Effect.Effect<TValue, FilesystemFailure, TRequirements>,
23
+ ): Effect.Effect<TValue, SetupFailure, TRequirements> => Effect.mapError(effect, (failure) => new SetupFailure({ message: failure.message }));
24
24
 
25
25
  export const scan = (config: ResolvedConfig): Effect.Effect<Pick<Session, "inventory" | "violations">, SetupFailure, FileSystem.FileSystem> =>
26
26
  Effect.gen(function* () {
@@ -13,8 +13,8 @@ export const keyOf = (rule: string, file: string): string => `${rule}\u0000${fil
13
13
  export const byLocation = (left: Violation, right: Violation): number =>
14
14
  left.rule.localeCompare(right.rule) || left.file.localeCompare(right.file) || (left.line ?? 0) - (right.line ?? 0);
15
15
 
16
- export const groupBy = <Item>(items: readonly Item[], key: (item: Item) => string): ReadonlyMap<string, readonly Item[]> => {
17
- const groups = new Map<string, Item[]>();
16
+ export const groupBy = <TItem>(items: readonly TItem[], key: (item: TItem) => string): ReadonlyMap<string, readonly TItem[]> => {
17
+ const groups = new Map<string, TItem[]>();
18
18
  for (const item of items) {
19
19
  const group = groups.get(key(item));
20
20
  if (group === undefined) {
@@ -1,71 +1,62 @@
1
1
  import { join } from "node:path";
2
- import { Console, Effect, FileSystem } from "effect";
2
+ import { Console, Effect, Schema } from "effect";
3
3
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
- import type { Scope } from "#baseline/update.ts";
5
- import { shrinkBaseline } from "#cli/baseline.ts";
6
4
  import { lint } from "#cli/lint.ts";
7
- import { loadConfig, type ResolvedConfig } from "#config/load.ts";
5
+ import { loadConfig } from "#config/load.ts";
6
+ import { decodeWith, validOrFail } from "#decoded.ts";
8
7
  import { GateFailed, SetupFailure } from "#failure.ts";
9
- import { gitOrFail } from "#git/command.ts";
10
- import { changesSince } from "#git/history.ts";
8
+ import { readOptionalText } from "#inventory/filesystem.ts";
11
9
 
12
10
  const COMMIT_AGAIN =
13
11
  "help: fix what the output above reports, then commit again; `quality hooks pre-commit` repeats these checks without committing.";
14
12
 
15
- // A partly staged file is counted as it is on disk, which is not what the commit holds, so its entries wait for a later commit.
16
- const stagedScope = Effect.fnUntraced(function* (config: ResolvedConfig) {
17
- const unstaged = (yield* changesSince(config.root, [])).files;
18
- if (unstaged.has(config.baseline)) {
19
- return undefined;
20
- }
21
- const staged = yield* changesSince(config.root, ["--cached"]);
22
- const held = new Set([...unstaged, ...[...staged.moves].filter(([, to]) => unstaged.has(to)).map(([from]) => from)]);
23
- const scope: Scope = {
24
- files: new Set([...staged.files].filter((file) => !held.has(file))),
25
- moves: new Map([...staged.moves].filter(([from]) => !held.has(from))),
26
- };
27
- return scope;
28
- });
13
+ const Manifest = Schema.toStandardSchemaV1(
14
+ Schema.fromJsonString(
15
+ Schema.Struct({
16
+ packageManager: Schema.optionalKey(Schema.String),
17
+ scripts: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
18
+ }),
19
+ ),
20
+ );
29
21
 
30
- const tighten = Effect.fnUntraced(function* (cwd: string, path: string | undefined, config: ResolvedConfig) {
31
- const scope = yield* stagedScope(config);
32
- if (scope === undefined) {
33
- return yield* Console.error(
34
- `quality: left ${config.baseline} as it is, because it has unstaged changes.\nhelp: stage or discard them to tighten it.`,
35
- );
22
+ const commitScripts = Effect.fnUntraced(function* (root: string) {
23
+ const file = join(root, "package.json");
24
+ const text = yield* readOptionalText(file).pipe(Effect.mapError((error) => new SetupFailure({ message: error.message })));
25
+ if (text === undefined) {
26
+ return { manager: "npm", scripts: [] };
36
27
  }
37
- yield* shrinkBaseline(cwd, path, () => Effect.succeed(scope));
38
- const fs = yield* FileSystem.FileSystem;
39
- if (yield* Effect.orElseSucceed(fs.exists(join(config.root, config.baseline)), () => false)) {
40
- yield* gitOrFail(config.root, ["add", "--", config.baseline]);
28
+ const manifest = yield* validOrFail(file, yield* Effect.promise(() => decodeWith(Manifest, text)));
29
+ const manager = manifest.packageManager?.split("@")[0] ?? "npm";
30
+ if (!["npm", "pnpm", "yarn", "bun"].includes(manager)) {
31
+ return yield* new SetupFailure({ message: `${file}: unsupported packageManager "${manager}". Use npm, pnpm, yarn or bun.` });
41
32
  }
33
+ return { manager, scripts: ["typecheck", "quality:commit"].filter((script) => manifest.scripts?.[script] !== undefined) };
42
34
  });
43
35
 
44
- const shell = Effect.fnUntraced(
45
- function* (root: string, command: string) {
36
+ const runScript = Effect.fnUntraced(
37
+ function* (root: string, manager: string, script: string) {
46
38
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
47
39
  const child = yield* spawner.spawn(
48
- ChildProcess.make("sh", ["-c", command], { cwd: root, stderr: "inherit", stdin: "inherit", stdout: "inherit" }),
40
+ ChildProcess.make(manager, ["run", script], { cwd: root, stderr: "inherit", stdin: "inherit", stdout: "inherit" }),
49
41
  );
50
42
  return Number(yield* child.exitCode);
51
43
  },
52
44
  Effect.scoped,
53
- Effect.mapError((error) => new SetupFailure({ message: `cannot run a pre-commit command: ${error.message}` })),
45
+ Effect.mapError((error) => new SetupFailure({ message: `cannot run a pre-commit script: ${error.message}` })),
54
46
  );
55
47
 
56
48
  export const preCommitHook = Effect.fn("Hooks.preCommitHook")(function* (cwd: string, path: string | undefined) {
57
49
  const config = yield* loadConfig(cwd, path);
58
- if (config.preCommit.tighten) {
59
- yield* tighten(cwd, path, config);
60
- }
50
+ const { manager, scripts } = yield* commitScripts(config.root);
61
51
  const linted = yield* lint(cwd, path, "summary").pipe(
62
52
  Effect.as(true),
63
53
  Effect.catchTag("GateFailed", () => Effect.succeed(false)),
64
54
  );
65
55
  const failed = linted ? [] : ["quality lint"];
66
- for (const command of config.preCommit.run) {
56
+ for (const script of scripts) {
57
+ const command = `${manager} run ${script}`;
67
58
  yield* Console.log(`quality: running \`${command}\``);
68
- const code = yield* shell(config.root, command);
59
+ const code = yield* runScript(config.root, manager, script);
69
60
  if (code !== 0) {
70
61
  failed.push(`\`${command}\` (exit code ${code})`);
71
62
  }