@shivaedev/quality 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -1
  4. package/dist/baseline/compare.d.ts +26 -0
  5. package/dist/baseline/compare.d.ts.map +1 -0
  6. package/dist/baseline/compare.js +42 -0
  7. package/dist/baseline/compare.js.map +1 -0
  8. package/dist/baseline/format.d.ts +13 -0
  9. package/dist/baseline/format.d.ts.map +1 -0
  10. package/dist/baseline/format.js +32 -0
  11. package/dist/baseline/format.js.map +1 -0
  12. package/dist/baseline/update.d.ts +24 -0
  13. package/dist/baseline/update.d.ts.map +1 -0
  14. package/dist/baseline/update.js +60 -0
  15. package/dist/baseline/update.js.map +1 -0
  16. package/dist/cli/args.d.ts +25 -0
  17. package/dist/cli/args.d.ts.map +1 -0
  18. package/dist/cli/args.js +64 -0
  19. package/dist/cli/args.js.map +1 -0
  20. package/dist/cli/baseline.d.ts +5 -0
  21. package/dist/cli/baseline.d.ts.map +1 -0
  22. package/dist/cli/baseline.js +37 -0
  23. package/dist/cli/baseline.js.map +1 -0
  24. package/dist/cli/lint.d.ts +5 -0
  25. package/dist/cli/lint.d.ts.map +1 -0
  26. package/dist/cli/lint.js +20 -0
  27. package/dist/cli/lint.js.map +1 -0
  28. package/dist/cli/run-main.d.ts +3 -0
  29. package/dist/cli/run-main.d.ts.map +1 -0
  30. package/dist/cli/run-main.js +24 -0
  31. package/dist/cli/run-main.js.map +1 -0
  32. package/dist/cli.d.ts +3 -0
  33. package/dist/cli.d.ts.map +1 -0
  34. package/dist/cli.js +27 -0
  35. package/dist/cli.js.map +1 -0
  36. package/dist/config/decode.d.ts +19 -0
  37. package/dist/config/decode.d.ts.map +1 -0
  38. package/dist/config/decode.js +25 -0
  39. package/dist/config/decode.js.map +1 -0
  40. package/dist/config/load.d.ts +13 -0
  41. package/dist/config/load.d.ts.map +1 -0
  42. package/dist/config/load.js +47 -0
  43. package/dist/config/load.js.map +1 -0
  44. package/dist/config/resolve.d.ts +11 -0
  45. package/dist/config/resolve.d.ts.map +1 -0
  46. package/dist/config/resolve.js +31 -0
  47. package/dist/config/resolve.js.map +1 -0
  48. package/dist/config.d.ts +31 -0
  49. package/dist/config.d.ts.map +1 -0
  50. package/dist/config.js +2 -0
  51. package/dist/config.js.map +1 -0
  52. package/dist/decoded.d.ts +13 -0
  53. package/dist/decoded.d.ts.map +1 -0
  54. package/dist/decoded.js +11 -0
  55. package/dist/decoded.js.map +1 -0
  56. package/dist/engine/evaluate.d.ts +18 -0
  57. package/dist/engine/evaluate.d.ts.map +1 -0
  58. package/dist/engine/evaluate.js +20 -0
  59. package/dist/engine/evaluate.js.map +1 -0
  60. package/dist/engine/run-rules.d.ts +12 -0
  61. package/dist/engine/run-rules.d.ts.map +1 -0
  62. package/dist/engine/run-rules.js +19 -0
  63. package/dist/engine/run-rules.js.map +1 -0
  64. package/dist/engine/session.d.ts +19 -0
  65. package/dist/engine/session.d.ts.map +1 -0
  66. package/dist/engine/session.js +25 -0
  67. package/dist/engine/session.js.map +1 -0
  68. package/dist/engine/violation.d.ts +12 -0
  69. package/dist/engine/violation.d.ts.map +1 -0
  70. package/dist/engine/violation.js +23 -0
  71. package/dist/engine/violation.js.map +1 -0
  72. package/dist/exceptions/registry.d.ts +24 -0
  73. package/dist/exceptions/registry.d.ts.map +1 -0
  74. package/dist/exceptions/registry.js +33 -0
  75. package/dist/exceptions/registry.js.map +1 -0
  76. package/dist/failure.d.ts +21 -0
  77. package/dist/failure.d.ts.map +1 -0
  78. package/dist/failure.js +12 -0
  79. package/dist/failure.js.map +1 -0
  80. package/dist/index.d.ts +6 -0
  81. package/dist/index.d.ts.map +1 -0
  82. package/dist/index.js +3 -0
  83. package/dist/index.js.map +1 -0
  84. package/dist/inventory/collect.d.ts +15 -0
  85. package/dist/inventory/collect.d.ts.map +1 -0
  86. package/dist/inventory/collect.js +34 -0
  87. package/dist/inventory/collect.js.map +1 -0
  88. package/dist/inventory/filesystem.d.ts +16 -0
  89. package/dist/inventory/filesystem.d.ts.map +1 -0
  90. package/dist/inventory/filesystem.js +26 -0
  91. package/dist/inventory/filesystem.js.map +1 -0
  92. package/dist/inventory/ignore-scope.d.ts +17 -0
  93. package/dist/inventory/ignore-scope.d.ts.map +1 -0
  94. package/dist/inventory/ignore-scope.js +26 -0
  95. package/dist/inventory/ignore-scope.js.map +1 -0
  96. package/dist/inventory/walk.d.ts +8 -0
  97. package/dist/inventory/walk.d.ts.map +1 -0
  98. package/dist/inventory/walk.js +38 -0
  99. package/dist/inventory/walk.js.map +1 -0
  100. package/dist/report/plural.d.ts +2 -0
  101. package/dist/report/plural.d.ts.map +1 -0
  102. package/dist/report/plural.js +2 -0
  103. package/dist/report/plural.js.map +1 -0
  104. package/dist/report/render.d.ts +11 -0
  105. package/dist/report/render.d.ts.map +1 -0
  106. package/dist/report/render.js +55 -0
  107. package/dist/report/render.js.map +1 -0
  108. package/dist/report/stale.d.ts +8 -0
  109. package/dist/report/stale.d.ts.map +1 -0
  110. package/dist/report/stale.js +9 -0
  111. package/dist/report/stale.js.map +1 -0
  112. package/dist/rule.d.ts +56 -0
  113. package/dist/rule.d.ts.map +1 -0
  114. package/dist/rule.js +23 -0
  115. package/dist/rule.js.map +1 -0
  116. package/dist/rules/built-in.d.ts +6 -0
  117. package/dist/rules/built-in.d.ts.map +1 -0
  118. package/dist/rules/built-in.js +3 -0
  119. package/dist/rules/built-in.js.map +1 -0
  120. package/dist/rules/max-lines.d.ts +6 -0
  121. package/dist/rules/max-lines.d.ts.map +1 -0
  122. package/dist/rules/max-lines.js +26 -0
  123. package/dist/rules/max-lines.js.map +1 -0
  124. package/dist/standard-schema.d.ts +25 -0
  125. package/dist/standard-schema.d.ts.map +1 -0
  126. package/dist/standard-schema.js +3 -0
  127. package/dist/standard-schema.js.map +1 -0
  128. package/package.json +53 -4
  129. package/src/baseline/compare.ts +81 -0
  130. package/src/baseline/format.ts +53 -0
  131. package/src/baseline/update.ts +83 -0
  132. package/src/cli/args.ts +81 -0
  133. package/src/cli/baseline.ts +52 -0
  134. package/src/cli/lint.ts +27 -0
  135. package/src/cli/run-main.ts +29 -0
  136. package/src/cli.ts +29 -0
  137. package/src/config/decode.ts +35 -0
  138. package/src/config/load.ts +58 -0
  139. package/src/config/resolve.ts +60 -0
  140. package/src/config.ts +33 -0
  141. package/src/decoded.ts +15 -0
  142. package/src/engine/evaluate.ts +39 -0
  143. package/src/engine/run-rules.ts +40 -0
  144. package/src/engine/session.ts +40 -0
  145. package/src/engine/violation.ts +35 -0
  146. package/src/exceptions/registry.ts +58 -0
  147. package/src/failure.ts +13 -0
  148. package/src/index.ts +5 -0
  149. package/src/inventory/collect.ts +53 -0
  150. package/src/inventory/filesystem.ts +47 -0
  151. package/src/inventory/ignore-scope.ts +44 -0
  152. package/src/inventory/walk.ts +58 -0
  153. package/src/report/plural.ts +1 -0
  154. package/src/report/render.ts +85 -0
  155. package/src/report/stale.ts +30 -0
  156. package/src/rule.ts +90 -0
  157. package/src/rules/built-in.ts +3 -0
  158. package/src/rules/max-lines.ts +31 -0
  159. package/src/standard-schema.ts +22 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"max-lines.d.ts","sourceRoot":"","sources":["../../src/rules/max-lines.ts"],"names":[],"mappings":"AAoBA,eAAO,MAAM,QAAQ;;;;EAUnB,CAAC"}
@@ -0,0 +1,26 @@
1
+ import { Effect, Schema } from "effect";
2
+ import ignore from "ignore";
3
+ import { defineRule } from "../rule.js";
4
+ const DEFAULT_TEST_FILES = ["*.test.*", "*.spec.*", "test/", "tests/", "__tests__/"];
5
+ const DECLARATION = /\.d\.[cm]?ts$/;
6
+ const Limit = Schema.Int.check(Schema.isGreaterThan(0));
7
+ const MaxLinesOptions = Schema.Struct({
8
+ source: Limit.pipe(Schema.withDecodingDefaultKey(Effect.succeed(150))),
9
+ test: Limit.pipe(Schema.withDecodingDefaultKey(Effect.succeed(300))),
10
+ testFiles: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(DEFAULT_TEST_FILES))),
11
+ });
12
+ const oversized = (file, limit) => file.lines.length <= limit
13
+ ? []
14
+ : [{ file: file.path, measure: file.lines.length, message: `${file.lines.length} lines exceeds the ${limit}-line limit.` }];
15
+ export const maxLines = defineRule({
16
+ id: "structure/max-lines",
17
+ description: "Keep each module to one job. Split a long file along its responsibilities; never golf it under the limit.",
18
+ options: Schema.toStandardSchemaV1(MaxLinesOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
19
+ check: ({ options, sources }) => {
20
+ const tests = ignore().add([...options.testFiles]);
21
+ return sources
22
+ .filter((file) => !DECLARATION.test(file.path))
23
+ .flatMap((file) => oversized(file, tests.ignores(file.path) ? options.test : options.source));
24
+ },
25
+ });
26
+ //# sourceMappingURL=max-lines.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"max-lines.js","sourceRoot":"","sources":["../../src/rules/max-lines.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,MAAM,MAAM,QAAQ,CAAC;AAC5B,OAAO,EAAE,UAAU,EAAiC,MAAM,YAAY,CAAC;AAEvE,MAAM,kBAAkB,GAA0B,CAAC,UAAU,EAAE,UAAU,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,CAAC,CAAC;AAC5G,MAAM,WAAW,GAAG,eAAe,CAAC;AAEpC,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC;AAExD,MAAM,eAAe,GAAG,MAAM,CAAC,MAAM,CAAC;IACrC,MAAM,EAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;IACtE,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;IACpE,SAAS,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,MAAM,CAAC,OAAO,CAAC,kBAAkB,CAAC,CAAC,CAAC;CAC9G,CAAC,CAAC;AAEH,MAAM,SAAS,GAAG,CAAC,IAAgB,EAAE,KAAa,EAA0B,EAAE,CAC7E,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,KAAK;IACzB,CAAC,CAAC,EAAE;IACJ,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,sBAAsB,KAAK,cAAc,EAAE,CAAC,CAAC;AAE9H,MAAM,CAAC,MAAM,QAAQ,GAAG,UAAU,CAAC;IAClC,EAAE,EAAE,qBAAqB;IACzB,WAAW,EAAE,2GAA2G;IACxH,OAAO,EAAE,MAAM,CAAC,kBAAkB,CAAC,eAAe,EAAE,EAAE,YAAY,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,EAAE,OAAO,EAAE,EAAE,CAAC;IACnH,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,EAAE,EAAE;QAC/B,MAAM,KAAK,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,CAAC,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC;QACnD,OAAO,OAAO;aACZ,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;aAC9C,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;IAChG,CAAC;CACD,CAAC,CAAC"}
@@ -0,0 +1,25 @@
1
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
2
+ readonly "~standard": {
3
+ readonly version: 1;
4
+ readonly vendor: string;
5
+ readonly validate: (value: unknown) => StandardResult<Output> | Promise<StandardResult<Output>>;
6
+ readonly types?: {
7
+ readonly input: Input;
8
+ readonly output: Output;
9
+ } | undefined;
10
+ };
11
+ }
12
+ export type StandardResult<Output> = {
13
+ readonly value: Output;
14
+ readonly issues?: undefined;
15
+ } | {
16
+ readonly issues: ReadonlyArray<StandardIssue>;
17
+ };
18
+ export interface StandardIssue {
19
+ readonly message: string;
20
+ readonly path?: ReadonlyArray<PropertyKey | {
21
+ readonly key: PropertyKey;
22
+ }> | undefined;
23
+ }
24
+ export declare const describeIssue: (issue: StandardIssue) => string;
25
+ //# sourceMappingURL=standard-schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"standard-schema.d.ts","sourceRoot":"","sources":["../src/standard-schema.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,gBAAgB,CAAC,KAAK,GAAG,OAAO,EAAE,MAAM,GAAG,KAAK;IAChE,QAAQ,CAAC,WAAW,EAAE;QACrB,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;QACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QACxB,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,cAAc,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,CAAC,CAAC;QAChG,QAAQ,CAAC,KAAK,CAAC,EAAE;YAAE,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC;YAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;SAAE,GAAG,SAAS,CAAC;KAChF,CAAC;CACF;AAED,MAAM,MAAM,cAAc,CAAC,MAAM,IAAI;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,aAAa,CAAC,CAAA;CAAE,CAAC;AAEjJ,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,aAAa,CAAC,WAAW,GAAG;QAAE,QAAQ,CAAC,GAAG,EAAE,WAAW,CAAA;KAAE,CAAC,GAAG,SAAS,CAAC;CACvF;AAID,eAAO,MAAM,aAAa,GAAI,OAAO,aAAa,KAAG,MAC0E,CAAC"}
@@ -0,0 +1,3 @@
1
+ const segment = (part) => String(typeof part === "object" ? part.key : part);
2
+ export const describeIssue = (issue) => issue.path === undefined || issue.path.length === 0 ? issue.message : `${issue.path.map(segment).join(".")}: ${issue.message}`;
3
+ //# sourceMappingURL=standard-schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"standard-schema.js","sourceRoot":"","sources":["../src/standard-schema.ts"],"names":[],"mappings":"AAkBA,MAAM,OAAO,GAAG,CAAC,IAAiD,EAAU,EAAE,CAAC,MAAM,CAAC,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;AAElI,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,KAAoB,EAAU,EAAE,CAC7D,KAAK,CAAC,IAAI,KAAK,SAAS,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC"}
package/package.json CHANGED
@@ -1,14 +1,63 @@
1
1
  {
2
2
  "name": "@shivaedev/quality",
3
- "version": "0.0.0",
4
- "description": "Name reservation. The first release is published from https://github.com/ShivaeDev/platform.",
3
+ "version": "0.1.0",
4
+ "description": "Repository quality gate with typed rules, a shrink-only baseline and a registry of reasoned exceptions",
5
+ "type": "module",
5
6
  "license": "MIT",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/ShivaeDev/platform.git",
9
10
  "directory": "packages/quality"
10
11
  },
12
+ "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/quality#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/ShivaeDev/platform/issues"
15
+ },
16
+ "engines": {
17
+ "node": ">=24"
18
+ },
19
+ "sideEffects": false,
20
+ "bin": {
21
+ "quality": "./dist/cli.js"
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "src",
26
+ "CHANGELOG.md",
27
+ "README.md"
28
+ ],
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "source": "./src/index.ts",
33
+ "import": "./dist/index.js",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
11
38
  "publishConfig": {
12
- "access": "public"
39
+ "access": "public",
40
+ "provenance": true
41
+ },
42
+ "dependencies": {
43
+ "@effect/platform-node": "4.0.0-rc.112",
44
+ "effect": "4.0.0-rc.112",
45
+ "ignore": "7.0.6"
46
+ },
47
+ "devDependencies": {
48
+ "@effect/vitest": "4.0.0-rc.112",
49
+ "@types/node": "24.10.1",
50
+ "@typescript/native": "npm:typescript@7.0.2",
51
+ "typescript": "npm:@typescript/typescript6@6.0.2",
52
+ "vitest": "4.1.9"
53
+ },
54
+ "scripts": {
55
+ "build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc6 --project tsconfig.build.json",
56
+ "check": "biome check .",
57
+ "test": "vitest run",
58
+ "test:package": "node scripts/test-package.mjs",
59
+ "typecheck": "tsc --noEmit",
60
+ "typecheck:compat": "tsc6 --noEmit",
61
+ "ready": "pnpm check && pnpm typecheck && pnpm typecheck:compat && pnpm test && pnpm build && pnpm test:package"
13
62
  }
14
- }
63
+ }
@@ -0,0 +1,81 @@
1
+ import type { Level } from "../config.ts";
2
+ import { groupBy, keyOf, unusedEntryProblem, type Violation } from "../engine/violation.ts";
3
+ import type { BaselineEntry } from "./format.ts";
4
+
5
+ export interface Tally {
6
+ readonly count: number;
7
+ readonly measure: number | undefined;
8
+ }
9
+
10
+ export interface Regression {
11
+ readonly entry: BaselineEntry;
12
+ readonly tally: Tally;
13
+ }
14
+
15
+ export interface StaleBaselineEntry {
16
+ readonly entry: BaselineEntry;
17
+ readonly problem: string;
18
+ }
19
+
20
+ export interface BaselineCheck {
21
+ readonly kept: ReadonlyArray<Violation>;
22
+ readonly regressions: ReadonlyArray<Regression>;
23
+ readonly stale: ReadonlyArray<StaleBaselineEntry>;
24
+ readonly baselined: number;
25
+ }
26
+
27
+ type Verdict = { readonly _tag: "Covered"; readonly loose: boolean } | { readonly _tag: "Regressed" };
28
+
29
+ const measures = (violations: ReadonlyArray<Violation>): ReadonlyArray<number> =>
30
+ violations.flatMap((violation) => (violation.measure === undefined ? [] : [violation.measure]));
31
+
32
+ export const tallyOf = (violations: ReadonlyArray<Violation>): Tally => {
33
+ const found = measures(violations);
34
+ return { count: violations.length, measure: found.length === 0 ? undefined : Math.max(...found) };
35
+ };
36
+
37
+ const measureTrend = (entry: BaselineEntry, tally: Tally): number =>
38
+ entry.measure === undefined || tally.measure === undefined ? 0 : Math.sign(tally.measure - entry.measure);
39
+
40
+ const verdictOf = (entry: BaselineEntry, tally: Tally): Verdict => {
41
+ const trend = measureTrend(entry, tally);
42
+ if (tally.count > entry.count || trend > 0) {
43
+ return { _tag: "Regressed" };
44
+ }
45
+ return { _tag: "Covered", loose: tally.count < entry.count || trend < 0 };
46
+ };
47
+
48
+ export const describeTally = (tally: Tally): string =>
49
+ `${tally.count} violation${tally.count === 1 ? "" : "s"}${tally.measure === undefined ? "" : ` measuring ${tally.measure}`}`;
50
+
51
+ export const describeEntry = (entry: BaselineEntry): string => describeTally({ count: entry.count, measure: entry.measure });
52
+
53
+ export const applyBaseline = (
54
+ violations: ReadonlyArray<Violation>,
55
+ entries: ReadonlyArray<BaselineEntry>,
56
+ levels: ReadonlyMap<string, Level>,
57
+ ): BaselineCheck => {
58
+ const groups = groupBy(violations, (violation) => keyOf(violation.rule, violation.file));
59
+ const covered = new Set<string>();
60
+ const regressions: Regression[] = [];
61
+ const stale: StaleBaselineEntry[] = [];
62
+ for (const entry of entries) {
63
+ const group = groups.get(keyOf(entry.rule, entry.file)) ?? [];
64
+ if (group.length === 0) {
65
+ stale.push({ entry, problem: unusedEntryProblem(levels, entry.rule, "has no violations left") });
66
+ continue;
67
+ }
68
+ const tally = tallyOf(group);
69
+ const verdict = verdictOf(entry, tally);
70
+ if (verdict._tag === "Regressed") {
71
+ regressions.push({ entry, tally });
72
+ continue;
73
+ }
74
+ covered.add(keyOf(entry.rule, entry.file));
75
+ if (verdict.loose) {
76
+ stale.push({ entry, problem: `allows more than is left: ${describeTally(tally)} against ${describeEntry(entry)} baselined` });
77
+ }
78
+ }
79
+ const kept = violations.filter((violation) => !covered.has(keyOf(violation.rule, violation.file)));
80
+ return { baselined: violations.length - kept.length, kept, regressions, stale };
81
+ };
@@ -0,0 +1,53 @@
1
+ import { Schema } from "effect";
2
+ import { type Decoded, decodeWith } from "../decoded.ts";
3
+
4
+ const Entry = Schema.Struct({
5
+ count: Schema.Int.check(Schema.isGreaterThan(0)),
6
+ measure: Schema.optionalKey(Schema.Finite),
7
+ });
8
+
9
+ const BaselineFile = Schema.Record(Schema.String, Schema.Record(Schema.String, Entry));
10
+
11
+ const standard = Schema.toStandardSchemaV1(Schema.fromJsonString(BaselineFile), { parseOptions: { errors: "all", onExcessProperty: "error" } });
12
+
13
+ export interface BaselineEntry {
14
+ readonly rule: string;
15
+ readonly file: string;
16
+ readonly count: number;
17
+ readonly measure?: number | undefined;
18
+ }
19
+
20
+ type Stored = typeof Entry.Type;
21
+
22
+ const codepoints = (left: string, right: string): number => (left < right ? -1 : Number(left > right));
23
+
24
+ export const byRuleAndFile = (left: BaselineEntry, right: BaselineEntry): number =>
25
+ codepoints(left.rule, right.rule) || codepoints(left.file, right.file);
26
+
27
+ export const decodeBaseline = async (raw: string | undefined): Promise<Decoded<ReadonlyArray<BaselineEntry>>> => {
28
+ if (raw === undefined) {
29
+ return { _tag: "Valid", value: [] };
30
+ }
31
+ const decoded = await decodeWith(standard, raw);
32
+ if (decoded._tag === "Invalid") {
33
+ return decoded;
34
+ }
35
+ const entries = Object.entries(decoded.value).flatMap(([rule, files]) =>
36
+ Object.entries(files).map(([file, stored]) => ({ ...stored, file, rule })),
37
+ );
38
+ return { _tag: "Valid", value: entries };
39
+ };
40
+
41
+ const stored = (entry: BaselineEntry): Stored =>
42
+ entry.measure === undefined ? { count: entry.count } : { count: entry.count, measure: entry.measure };
43
+
44
+ export const encodeBaseline = (entries: ReadonlyArray<BaselineEntry>, indent: string): string => {
45
+ const rules: Record<string, Record<string, Stored>> = {};
46
+ for (const entry of [...entries].sort(byRuleAndFile)) {
47
+ rules[entry.rule] = { ...rules[entry.rule], [entry.file]: stored(entry) };
48
+ }
49
+ return `${JSON.stringify(rules, null, indent)}\n`;
50
+ };
51
+
52
+ /** Keeps the indentation of the file being rewritten, so the repository formatter leaves it alone. */
53
+ export const indentOf = (raw: string | undefined): string => /^([ \t]+)\S/m.exec(raw ?? "")?.[1] ?? "\t";
@@ -0,0 +1,83 @@
1
+ import type { Level } from "../config.ts";
2
+ import { groupBy, keyOf, type Violation } from "../engine/violation.ts";
3
+ import { tallyOf } from "./compare.ts";
4
+ import type { BaselineEntry } from "./format.ts";
5
+
6
+ export type Adoption =
7
+ | { readonly _tag: "Refused"; readonly reasons: ReadonlyArray<string> }
8
+ | { readonly _tag: "Adopted"; readonly entries: ReadonlyArray<BaselineEntry>; readonly added: number };
9
+
10
+ export interface Pruned {
11
+ readonly entries: ReadonlyArray<BaselineEntry>;
12
+ readonly removed: number;
13
+ readonly lowered: number;
14
+ }
15
+
16
+ const record = (violations: ReadonlyArray<Violation>): ReadonlyArray<BaselineEntry> =>
17
+ [...groupBy(violations, (violation) => keyOf(violation.rule, violation.file)).values()].flatMap((group) => {
18
+ const first = group[0];
19
+ if (first === undefined) {
20
+ return [];
21
+ }
22
+ const tally = tallyOf(group);
23
+ return [
24
+ tally.measure === undefined ? { count: tally.count, file: first.file, rule: first.rule } : { ...tally, file: first.file, rule: first.rule },
25
+ ];
26
+ });
27
+
28
+ const refusal = (id: string, levels: ReadonlyMap<string, Level>, existing: ReadonlyArray<BaselineEntry>): ReadonlyArray<string> => {
29
+ const level = levels.get(id);
30
+ if (level === undefined) {
31
+ return [`${id}: no built-in or local rule has this id.`];
32
+ }
33
+ if (level !== "error") {
34
+ return [`${id}: the rule is ${level}. Set it to error before baselining it.`];
35
+ }
36
+ return existing.some((entry) => entry.rule === id) ? [`${id}: already baselined. Its entries only shrink; fix new violations instead.`] : [];
37
+ };
38
+
39
+ /**
40
+ * Records the error-level violations of rules being adopted. Without a baseline every rule is adopted at once; afterwards a
41
+ * rule is adopted by name, once, so a baseline never grows for a rule it already covers.
42
+ */
43
+ export const adopt = (
44
+ existing: ReadonlyArray<BaselineEntry> | undefined,
45
+ rules: ReadonlyArray<string>,
46
+ violations: ReadonlyArray<Violation>,
47
+ levels: ReadonlyMap<string, Level>,
48
+ ): Adoption => {
49
+ if (existing !== undefined && rules.length === 0) {
50
+ return { _tag: "Refused", reasons: ["a baseline exists and only shrinks. Adopt a new rule with --rule <id>, or drop fixed debt with prune."] };
51
+ }
52
+ const reasons = rules.flatMap((id) => refusal(id, levels, existing ?? []));
53
+ if (reasons.length > 0) {
54
+ return { _tag: "Refused", reasons };
55
+ }
56
+ const adopted = violations.filter((violation) => violation.level === "error" && (rules.length === 0 || rules.includes(violation.rule)));
57
+ const added = record(adopted);
58
+ return { _tag: "Adopted", added: added.length, entries: [...(existing ?? []), ...added] };
59
+ };
60
+
61
+ const shrunk = (entry: BaselineEntry, current: BaselineEntry): BaselineEntry => {
62
+ const count = Math.min(entry.count, current.count);
63
+ return entry.measure === undefined || current.measure === undefined
64
+ ? { count, file: entry.file, rule: entry.rule }
65
+ : { count, file: entry.file, measure: Math.min(entry.measure, current.measure), rule: entry.rule };
66
+ };
67
+
68
+ const unchanged = (left: BaselineEntry, right: BaselineEntry): boolean => left.count === right.count && left.measure === right.measure;
69
+
70
+ /** Drops fixed debt and lowers entries to what is left. It never adds an entry or raises one. */
71
+ export const prune = (existing: ReadonlyArray<BaselineEntry>, violations: ReadonlyArray<Violation>, levels: ReadonlyMap<string, Level>): Pruned => {
72
+ const current = new Map(record(violations).map((entry) => [keyOf(entry.rule, entry.file), entry]));
73
+ const kept = existing.flatMap((before) => {
74
+ const now = current.get(keyOf(before.rule, before.file));
75
+ const level = levels.get(before.rule);
76
+ return now === undefined || level === undefined || level === "off" ? [] : [{ after: shrunk(before, now), before }];
77
+ });
78
+ return {
79
+ entries: kept.map(({ after }) => after),
80
+ lowered: kept.filter(({ after, before }) => !unchanged(before, after)).length,
81
+ removed: existing.length - kept.length,
82
+ };
83
+ };
@@ -0,0 +1,81 @@
1
+ import { parseArgs } from "node:util";
2
+ import { Result } from "effect";
3
+ import type { WarningDetail } from "../report/render.ts";
4
+
5
+ export type Command =
6
+ | { readonly _tag: "Lint"; readonly config: string | undefined; readonly warnings: WarningDetail }
7
+ | { readonly _tag: "BaselineWrite"; readonly config: string | undefined; readonly rules: ReadonlyArray<string> }
8
+ | { readonly _tag: "BaselinePrune"; readonly config: string | undefined }
9
+ | { readonly _tag: "Help" };
10
+
11
+ export type Parsed = { readonly _tag: "Parsed"; readonly command: Command } | { readonly _tag: "Usage"; readonly problem: string };
12
+
13
+ export const USAGE = `Usage:
14
+ quality lint [--config <file>] [--warnings summary|all]
15
+ quality baseline write [--config <file>] [--rule <id>]...
16
+ quality baseline prune [--config <file>]
17
+
18
+ lint Run every rule. Exits 1 on an error-level violation, a baseline regression or a stale entry.
19
+ baseline write Record current error-level violations. Creates the baseline, or adopts named rules into an existing one.
20
+ baseline prune Drop fixed debt and lower entries to what is left. Never adds or raises an entry.
21
+
22
+ --config <file> Config file; its directory is the repository root. Defaults to ./quality.config.ts.
23
+ Exit codes: 0 passed, 1 failed the gate, 2 could not run.`;
24
+
25
+ const OPTIONS = {
26
+ config: { type: "string" },
27
+ help: { short: "h", type: "boolean" },
28
+ rule: { multiple: true, type: "string" },
29
+ warnings: { type: "string" },
30
+ } as const;
31
+
32
+ const usage = (problem: string): Parsed => ({ _tag: "Usage", problem });
33
+
34
+ const parsed = (command: Command): Parsed => ({ _tag: "Parsed", command });
35
+
36
+ type Values = ReturnType<typeof parseArgs<{ options: typeof OPTIONS; allowPositionals: true }>>["values"];
37
+
38
+ const lint = (values: Values): Parsed => {
39
+ if (values.rule !== undefined) {
40
+ return usage("--rule applies only to baseline write.");
41
+ }
42
+ const warnings = values.warnings ?? "summary";
43
+ return warnings === "summary" || warnings === "all"
44
+ ? parsed({ _tag: "Lint", config: values.config, warnings })
45
+ : usage(`--warnings takes summary or all, not "${warnings}".`);
46
+ };
47
+
48
+ const baseline = (action: string | undefined, values: Values): Parsed => {
49
+ if (values.warnings !== undefined) {
50
+ return usage("--warnings applies only to lint.");
51
+ }
52
+ if (action === "write") {
53
+ return parsed({ _tag: "BaselineWrite", config: values.config, rules: values.rule ?? [] });
54
+ }
55
+ if (action === "prune" && values.rule === undefined) {
56
+ return parsed({ _tag: "BaselinePrune", config: values.config });
57
+ }
58
+ return usage(action === "prune" ? "--rule applies only to baseline write." : "baseline takes write or prune.");
59
+ };
60
+
61
+ export const parseCommand = (args: ReadonlyArray<string>): Parsed => {
62
+ const result = Result.try({
63
+ catch: (error) => (error instanceof Error ? error.message : String(error)),
64
+ try: () => parseArgs({ allowPositionals: true, args: [...args], options: OPTIONS, strict: true }),
65
+ });
66
+ if (Result.isFailure(result)) {
67
+ return usage(result.failure);
68
+ }
69
+ const { positionals, values } = result.success;
70
+ const [command = "lint", action, ...extra] = positionals;
71
+ if (values.help === true || command === "help") {
72
+ return parsed({ _tag: "Help" });
73
+ }
74
+ if (command === "lint" && action === undefined) {
75
+ return lint(values);
76
+ }
77
+ if (command === "baseline" && extra.length === 0) {
78
+ return baseline(action, values);
79
+ }
80
+ return usage(`unknown command: ${positionals.join(" ")}`);
81
+ };
@@ -0,0 +1,52 @@
1
+ import { join } from "node:path";
2
+ import { Console, Effect, type FileSystem } from "effect";
3
+ import { type BaselineEntry, encodeBaseline, indentOf } from "../baseline/format.ts";
4
+ import { adopt, prune } from "../baseline/update.ts";
5
+ import { openSession, type Session } from "../engine/session.ts";
6
+ import { applyRegistry } from "../exceptions/registry.ts";
7
+ import { SetupFailure } from "../failure.ts";
8
+ import { writeText } from "../inventory/filesystem.ts";
9
+ import { plural } from "../report/plural.ts";
10
+
11
+ const save = (session: Session, entries: ReadonlyArray<BaselineEntry>): Effect.Effect<void, SetupFailure, FileSystem.FileSystem> =>
12
+ Effect.mapError(
13
+ writeText(join(session.config.root, session.config.baseline), encodeBaseline(entries, indentOf(session.baseline.raw))),
14
+ (failure) => new SetupFailure({ message: failure.message }),
15
+ );
16
+
17
+ const unregistered = (session: Session) => applyRegistry(session.violations, session.registry, session.config.levels).kept;
18
+
19
+ export const writeBaseline = (
20
+ cwd: string,
21
+ config: string | undefined,
22
+ rules: ReadonlyArray<string>,
23
+ ): Effect.Effect<void, SetupFailure, FileSystem.FileSystem> =>
24
+ Effect.gen(function* () {
25
+ const session = yield* openSession(cwd, config);
26
+ const existing = session.baseline.raw === undefined ? undefined : session.baseline.entries;
27
+ const adoption = adopt(existing, rules, unregistered(session), session.config.levels);
28
+ if (adoption._tag === "Refused") {
29
+ return yield* new SetupFailure({ message: `baseline write refused:\n${adoption.reasons.map((reason) => ` - ${reason}`).join("\n")}` });
30
+ }
31
+ if (adoption.added === 0) {
32
+ return yield* Console.log(`quality: no error-level violations to record; ${session.config.baseline} is unchanged.`);
33
+ }
34
+ yield* save(session, adoption.entries);
35
+ yield* Console.log(`quality: recorded ${plural(adoption.added, "entry", "entries")} in ${session.config.baseline}.`);
36
+ });
37
+
38
+ export const pruneBaseline = (cwd: string, config: string | undefined): Effect.Effect<void, SetupFailure, FileSystem.FileSystem> =>
39
+ Effect.gen(function* () {
40
+ const session = yield* openSession(cwd, config);
41
+ if (session.baseline.raw === undefined) {
42
+ return yield* Console.log(`quality: no baseline at ${session.config.baseline}; nothing to prune.`);
43
+ }
44
+ const pruned = prune(session.baseline.entries, unregistered(session), session.config.levels);
45
+ if (pruned.removed === 0 && pruned.lowered === 0) {
46
+ return yield* Console.log(`quality: ${session.config.baseline} is current.`);
47
+ }
48
+ yield* save(session, pruned.entries);
49
+ yield* Console.log(
50
+ `quality: removed ${plural(pruned.removed, "entry", "entries")} and lowered ${plural(pruned.lowered, "entry", "entries")} in ${session.config.baseline}.`,
51
+ );
52
+ });
@@ -0,0 +1,27 @@
1
+ import { Console, Effect, type FileSystem } from "effect";
2
+ import { evaluate, passes } from "../engine/evaluate.ts";
3
+ import { openSession } from "../engine/session.ts";
4
+ import { GateFailed, type SetupFailure } from "../failure.ts";
5
+ import { render, type WarningDetail } from "../report/render.ts";
6
+
7
+ export const lint = (
8
+ cwd: string,
9
+ config: string | undefined,
10
+ warnings: WarningDetail,
11
+ ): Effect.Effect<void, SetupFailure | GateFailed, FileSystem.FileSystem> =>
12
+ Effect.gen(function* () {
13
+ const session = yield* openSession(cwd, config);
14
+ const outcome = evaluate(session.violations, session.registry, session.baseline.entries, session.config.levels);
15
+ yield* Console.log(
16
+ render(outcome, {
17
+ baseline: session.config.baseline,
18
+ checked: session.inventory.sources.length,
19
+ descriptions: new Map(session.config.active.map((rule) => [rule.id, rule.description])),
20
+ registry: session.config.registry,
21
+ warnings,
22
+ }),
23
+ );
24
+ if (!passes(outcome)) {
25
+ return yield* new GateFailed();
26
+ }
27
+ });
@@ -0,0 +1,29 @@
1
+ import { NodeFileSystem, NodeRuntime } from "@effect/platform-node";
2
+ import { Cause, Console, Effect, Exit, type FileSystem, Runtime } from "effect";
3
+ import { SetupFailure } from "../failure.ts";
4
+
5
+ const INTERRUPTED = 130;
6
+ const COULD_NOT_RUN = 2;
7
+
8
+ const explain = (cause: Cause.Cause<unknown>): Effect.Effect<void> => {
9
+ const error = Cause.squash(cause);
10
+ if (error instanceof SetupFailure) {
11
+ return Console.error(`quality: ${error.message}`);
12
+ }
13
+ return Runtime.getErrorReported(error) ? Console.error(`quality: internal error\n${Cause.pretty(cause)}`) : Effect.void;
14
+ };
15
+
16
+ const exitCodeOf = (error: unknown): number =>
17
+ typeof error === "object" && error !== null && Runtime.errorExitCode in error ? Runtime.getErrorExitCode(error) : COULD_NOT_RUN;
18
+
19
+ const teardown: Runtime.Teardown = (exit, onExit) => {
20
+ if (Exit.isSuccess(exit)) {
21
+ return onExit(0);
22
+ }
23
+ return onExit(Cause.hasInterruptsOnly(exit.cause) ? INTERRUPTED : exitCodeOf(Cause.squash(exit.cause)));
24
+ };
25
+
26
+ export const runMain = <Value, Error>(program: Effect.Effect<Value, Error, FileSystem.FileSystem>): void => {
27
+ const explained = Effect.tapCause(program, (cause) => (Cause.hasInterruptsOnly(cause) ? Effect.void : explain(cause)));
28
+ NodeRuntime.runMain(Effect.provide(explained, NodeFileSystem.layer), { disableErrorReporting: true, teardown });
29
+ };
package/src/cli.ts ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import { Console, Effect } from "effect";
4
+ import { parseCommand, USAGE } from "./cli/args.ts";
5
+ import { pruneBaseline, writeBaseline } from "./cli/baseline.ts";
6
+ import { lint } from "./cli/lint.ts";
7
+ import { runMain } from "./cli/run-main.ts";
8
+ import { SetupFailure } from "./failure.ts";
9
+
10
+ const parsed = parseCommand(process.argv.slice(2));
11
+
12
+ const program = Effect.gen(function* () {
13
+ if (parsed._tag === "Usage") {
14
+ return yield* new SetupFailure({ message: `${parsed.problem}\n\n${USAGE}` });
15
+ }
16
+ const command = parsed.command;
17
+ switch (command._tag) {
18
+ case "Help":
19
+ return yield* Console.log(USAGE);
20
+ case "Lint":
21
+ return yield* lint(process.cwd(), command.config, command.warnings);
22
+ case "BaselineWrite":
23
+ return yield* writeBaseline(process.cwd(), command.config, command.rules);
24
+ case "BaselinePrune":
25
+ return yield* pruneBaseline(process.cwd(), command.config);
26
+ }
27
+ });
28
+
29
+ runMain(program);
@@ -0,0 +1,35 @@
1
+ import { Schema } from "effect";
2
+ import { type Decoded, decodeWith } from "../decoded.ts";
3
+ import type { Rule } from "../rule.ts";
4
+
5
+ const isRule = (value: unknown): value is Rule =>
6
+ typeof value === "object" &&
7
+ value !== null &&
8
+ "id" in value &&
9
+ typeof value.id === "string" &&
10
+ "description" in value &&
11
+ typeof value.description === "string" &&
12
+ "configure" in value &&
13
+ typeof value.configure === "function";
14
+
15
+ const Level = Schema.Literals(["error", "warn", "off"]);
16
+
17
+ const Setting = Schema.Union([Level, Schema.Struct({ level: Schema.optionalKey(Level), options: Schema.optionalKey(Schema.Unknown) })]);
18
+
19
+ const LocalRule = Schema.declare(isRule, { expected: "a rule made with defineRule" });
20
+
21
+ const ConfigInput = Schema.Struct({
22
+ sources: Schema.optionalKey(Schema.Array(Schema.String)),
23
+ exclude: Schema.optionalKey(Schema.Array(Schema.String)),
24
+ extensions: Schema.optionalKey(Schema.Array(Schema.String)),
25
+ registry: Schema.optionalKey(Schema.String),
26
+ baseline: Schema.optionalKey(Schema.String),
27
+ local: Schema.optionalKey(Schema.Array(LocalRule)),
28
+ rules: Schema.optionalKey(Schema.Record(Schema.String, Setting)),
29
+ });
30
+
31
+ export type ConfigInput = typeof ConfigInput.Type;
32
+
33
+ const standard = Schema.toStandardSchemaV1(ConfigInput, { parseOptions: { errors: "all", onExcessProperty: "error" } });
34
+
35
+ export const decodeConfig = (value: unknown): Promise<Decoded<ConfigInput>> => decodeWith(standard, value);