@yuu1111/quality-check 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md ADDED
@@ -0,0 +1,87 @@
1
+ [English](README.md)
2
+
3
+ # @yuu1111/quality-check
4
+
5
+ Projectごとのscriptから個別に呼んでいたBiome、Knip、comment-check、TSDoc checkerを1つのCLIへまとめる baselineの差分判定もここで行う
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add -D @yuu1111/quality-check
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ `quality.config.ts` でengineと除外pathを指定し、scriptから起動する
16
+
17
+ ```json
18
+ {
19
+ "scripts": {
20
+ "check:quality": "quality-check"
21
+ }
22
+ }
23
+ ```
24
+
25
+ ```ts
26
+ import { defineConfig } from "@yuu1111/quality-check";
27
+
28
+ export default defineConfig({
29
+ engines: {
30
+ biome: true,
31
+ knip: true,
32
+ "comment-check": true,
33
+ "tsdoc-check": { args: ["--error", "missing-doc"] },
34
+ },
35
+ ignore: ["FFXIVReplayAnalyzer"],
36
+ });
37
+ ```
38
+
39
+ engineごとの出力と集約summaryを並べて出し、どのengineが失敗したかを1回の実行で示す
40
+
41
+ ```text
42
+ == biome ==
43
+ Checked 128 files in 260ms. No fixes applied.
44
+ biome: passed (exit 0)
45
+
46
+ == comment-check ==
47
+ src/queue.ts:18:2 placeholder-comment placeholder comment should be resolved or tracked
48
+ comment-check: failed (1 new, 0 resolved, 0 warnings)
49
+
50
+ quality-check: 1 of 3 engines failed
51
+ failed: comment-check
52
+ passed: biome, tsdoc-check
53
+ ```
54
+
55
+ ## Config
56
+
57
+ | Field | Description |
58
+ |-------|-------------|
59
+ | `engines` | 起動するengine 値は `true` または `{ args }` |
60
+ | `ignore` | file走査engineが検査から外すpath |
61
+ | `baseline` | baseline fileのpath `false` なら差分判定を行わない |
62
+ | `targets` | file走査engineへ渡す対象path |
63
+
64
+ engineは `biome` → `knip` → `comment-check` → `tsdoc-check` の順に実行する
65
+ `args` はengineの既定引数の後ろへ足すため、`tsdoc-check` の `--error` のようなengine固有の指定はここへ置く
66
+
67
+ ## Options
68
+
69
+ | Option | Description |
70
+ |--------|-------------|
71
+ | `--config <path>` | 読み込むconfig file(既定は `quality.config.ts`) |
72
+ | `--baseline <path>` | baseline fileを上書きする |
73
+ | `--ignore <path>` | 除外pathを追加する 複数指定できる |
74
+ | `--update-baseline` | 現在の検出でbaselineを置き換える |
75
+ | `--json` | engineごとの結果をJSONで出力する |
76
+
77
+ ## Notes
78
+
79
+ 終了codeは0が全engine成功、1が失敗したengineあり、2が設定またはengine起動の失敗
80
+
81
+ BiomeとKnipは除外pathを自分のconfig(`biome.json`、`knip.ts`)で持つ `ignore` はfile走査engineへ `--ignore` として渡し、BiomeとKnipには渡さない
82
+
83
+ `comment-check` はbaseline差分を無効化する未作成のpathを渡して起動する 新規と解消済みの判定はengineごとではなく統合CLIが1つのbaseline fileで行うため、既存の `comment-baseline.json` がある場合は `--update-baseline` で移す
84
+
85
+ ## License
86
+
87
+ MIT
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ [日本語](README.ja.md)
2
+
3
+ # @yuu1111/quality-check
4
+
5
+ Runs Biome, Knip, comment-check, and the TSDoc checker from one CLI instead of
6
+ one script per project. The baseline diff lives here too.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ bun add -D @yuu1111/quality-check
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ Declare the engines and the excluded paths in `quality.config.ts`, then call the
17
+ CLI from a script:
18
+
19
+ ```json
20
+ {
21
+ "scripts": {
22
+ "check:quality": "quality-check"
23
+ }
24
+ }
25
+ ```
26
+
27
+ ```ts
28
+ import { defineConfig } from "@yuu1111/quality-check";
29
+
30
+ export default defineConfig({
31
+ engines: {
32
+ biome: true,
33
+ knip: true,
34
+ "comment-check": true,
35
+ "tsdoc-check": { args: ["--error", "missing-doc"] },
36
+ },
37
+ ignore: ["FFXIVReplayAnalyzer"],
38
+ });
39
+ ```
40
+
41
+ Each engine prints its own section, and the summary names the engines that
42
+ failed:
43
+
44
+ ```text
45
+ == biome ==
46
+ Checked 128 files in 260ms. No fixes applied.
47
+ biome: passed (exit 0)
48
+
49
+ == comment-check ==
50
+ src/queue.ts:18:2 placeholder-comment placeholder comment should be resolved or tracked
51
+ comment-check: failed (1 new, 0 resolved, 0 warnings)
52
+
53
+ quality-check: 1 of 3 engines failed
54
+ failed: comment-check
55
+ passed: biome, tsdoc-check
56
+ ```
57
+
58
+ ## Config
59
+
60
+ | Field | Description |
61
+ |-------|-------------|
62
+ | `engines` | Engines to run, as `true` or `{ args }` |
63
+ | `ignore` | Paths that the file-scanning engines leave out |
64
+ | `baseline` | Baseline file path; `false` disables the diff |
65
+ | `targets` | Targets passed to the file-scanning engines |
66
+
67
+ Engines run in the order `biome`, `knip`, `comment-check`, `tsdoc-check`.
68
+ `args` is appended after the engine defaults, so engine-specific flags such as
69
+ `--error` for `tsdoc-check` belong there.
70
+
71
+ ## Options
72
+
73
+ | Option | Description |
74
+ |--------|-------------|
75
+ | `--config <path>` | Config file to load (default `quality.config.ts`) |
76
+ | `--baseline <path>` | Override the baseline file |
77
+ | `--ignore <path>` | Add an excluded path, repeatable |
78
+ | `--update-baseline` | Replace the baseline with the current findings |
79
+ | `--json` | Print the per-engine results as JSON |
80
+
81
+ ## Notes
82
+
83
+ Exit code 0 means every engine passed, 1 that at least one failed, and 2 that the
84
+ configuration or an engine could not start.
85
+
86
+ Biome and Knip keep their excluded paths in their own config (`biome.json`,
87
+ `knip.ts`). `ignore` reaches the file-scanning engines as `--ignore` and is not
88
+ passed to Biome or Knip.
89
+
90
+ `comment-check` runs with an unwritten baseline path so that it reports every
91
+ finding. The new-and-resolved diff is done by this CLI from a single baseline
92
+ file, so an existing `comment-baseline.json` is moved over with
93
+ `--update-baseline`.
94
+
95
+ ## License
96
+
97
+ MIT
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@yuu1111/quality-check",
3
+ "version": "0.0.0",
4
+ "description": "Integrated quality check runner",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/yuu1111/configs.git",
9
+ "directory": "packages/quality-check"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "quality-check": "src/cli.ts"
14
+ },
15
+ "exports": {
16
+ ".": "./src/config.ts",
17
+ "./engines": "./src/engines.ts"
18
+ },
19
+ "files": [
20
+ "README.ja.md",
21
+ "src"
22
+ ],
23
+ "keywords": [
24
+ "quality",
25
+ "lint"
26
+ ]
27
+ }
@@ -0,0 +1,147 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import type { NormalizedFinding } from "./findings";
3
+
4
+ /**
5
+ * baselineへ記録する検出1件の識別情報と件数
6
+ */
7
+ export interface BaselineEntry {
8
+ count: number;
9
+ engine: string;
10
+ file: string;
11
+ rule: string;
12
+ text: string;
13
+ }
14
+
15
+ /**
16
+ * baseline fileの形式
17
+ */
18
+ export interface BaselineFile {
19
+ entries: BaselineEntry[];
20
+ version: 1;
21
+ }
22
+
23
+ /**
24
+ * baselineと現在の検出を比較した結果
25
+ */
26
+ export interface BaselineComparison {
27
+ added: NormalizedFinding[];
28
+ resolved: BaselineEntry[];
29
+ }
30
+
31
+ type JsonObject = Record<string, unknown>;
32
+
33
+ /**
34
+ * 検出をbaseline上で一意に識別するkeyに使う項目
35
+ */
36
+ export interface BaselineKey {
37
+ engine: string;
38
+ file: string;
39
+ rule: string;
40
+ text: string;
41
+ }
42
+
43
+ function isJsonObject(value: unknown): value is JsonObject {
44
+ return typeof value === "object" && value !== null && !Array.isArray(value);
45
+ }
46
+
47
+ function isBaselineEntry(value: unknown): value is BaselineEntry {
48
+ if (!isJsonObject(value)) {
49
+ return false;
50
+ }
51
+ return (
52
+ typeof value.engine === "string" &&
53
+ typeof value.rule === "string" &&
54
+ typeof value.file === "string" &&
55
+ typeof value.text === "string" &&
56
+ typeof value.count === "number"
57
+ );
58
+ }
59
+
60
+ function entryKey(entry: BaselineKey): string {
61
+ return [entry.engine, entry.rule, entry.file, entry.text].join("\u0000");
62
+ }
63
+
64
+ function compareEntries(left: BaselineEntry, right: BaselineEntry): number {
65
+ const leftKey = entryKey(left);
66
+ const rightKey = entryKey(right);
67
+ if (leftKey === rightKey) {
68
+ return 0;
69
+ }
70
+ return leftKey < rightKey ? -1 : 1;
71
+ }
72
+
73
+ /**
74
+ * 検出をengineとruleとfileと本文で集計してbaselineを作る
75
+ */
76
+ export function createBaseline(findings: NormalizedFinding[]): BaselineFile {
77
+ const entries = new Map<string, BaselineEntry>();
78
+ for (const finding of findings) {
79
+ const key = entryKey(finding);
80
+ const existing = entries.get(key);
81
+ if (existing) {
82
+ existing.count += 1;
83
+ continue;
84
+ }
85
+ entries.set(key, {
86
+ count: 1,
87
+ engine: finding.engine,
88
+ file: finding.file,
89
+ rule: finding.rule,
90
+ text: finding.text,
91
+ });
92
+ }
93
+ return { entries: [...entries.values()].sort(compareEntries), version: 1 };
94
+ }
95
+
96
+ /**
97
+ * 現在の検出からbaseline済みを除き、解消済みentryを求める
98
+ */
99
+ export function compareWithBaseline(
100
+ findings: NormalizedFinding[],
101
+ baseline: BaselineFile,
102
+ ): BaselineComparison {
103
+ const remaining = new Map<string, number>();
104
+ for (const entry of baseline.entries) {
105
+ const key = entryKey(entry);
106
+ remaining.set(key, (remaining.get(key) ?? 0) + entry.count);
107
+ }
108
+ const added: NormalizedFinding[] = [];
109
+ for (const finding of findings) {
110
+ const key = entryKey(finding);
111
+ const count = remaining.get(key) ?? 0;
112
+ if (count > 0) {
113
+ remaining.set(key, count - 1);
114
+ continue;
115
+ }
116
+ added.push(finding);
117
+ }
118
+ const resolved = baseline.entries.filter(
119
+ (entry) => (remaining.get(entryKey(entry)) ?? 0) > 0,
120
+ );
121
+ return { added, resolved };
122
+ }
123
+
124
+ /**
125
+ * baseline fileを読み込む 存在しない場合は空のbaselineを返す
126
+ */
127
+ export function readBaseline(path: string): BaselineFile {
128
+ if (!existsSync(path)) {
129
+ return { entries: [], version: 1 };
130
+ }
131
+ const value: unknown = JSON.parse(readFileSync(path, "utf8"));
132
+ if (
133
+ !isJsonObject(value) ||
134
+ !Array.isArray(value.entries) ||
135
+ !value.entries.every(isBaselineEntry)
136
+ ) {
137
+ throw new Error(`${path} is not a quality baseline`);
138
+ }
139
+ return { entries: value.entries, version: 1 };
140
+ }
141
+
142
+ /**
143
+ * baseline fileをタブ区切りのJSONで書き込む
144
+ */
145
+ export function writeBaseline(path: string, baseline: BaselineFile): void {
146
+ writeFileSync(path, `${JSON.stringify(baseline, null, "\t")}\n`);
147
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env bun
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { createBaseline, readBaseline, writeBaseline } from "./baseline";
5
+ import {
6
+ DEFAULT_BASELINE_FILE,
7
+ findConfigFile,
8
+ loadConfig,
9
+ type QualityConfig,
10
+ } from "./config";
11
+ import { formatEngineSection, formatSummary, toJsonReport } from "./report";
12
+ import { runEngines } from "./run";
13
+
14
+ const USAGE =
15
+ "Usage: quality-check [--config <path>] [--baseline <path>] [--ignore <path>] [--update-baseline] [--json] [path...]";
16
+
17
+ interface Options {
18
+ baselinePath: string | undefined;
19
+ configPath: string | undefined;
20
+ ignores: string[];
21
+ json: boolean;
22
+ targets: string[];
23
+ update: boolean;
24
+ }
25
+
26
+ function applyFlagOption(options: Options, argument: string): boolean {
27
+ if (argument === "--json") {
28
+ options.json = true;
29
+ return true;
30
+ }
31
+ if (argument === "--update-baseline") {
32
+ options.update = true;
33
+ return true;
34
+ }
35
+ return false;
36
+ }
37
+
38
+ function applyValueOption(
39
+ options: Options,
40
+ argument: string,
41
+ argv: string[],
42
+ index: number,
43
+ ): number | null {
44
+ if (argument === "--config") {
45
+ options.configPath = argv[index + 1];
46
+ return 1;
47
+ }
48
+ if (argument.startsWith("--config=")) {
49
+ options.configPath = argument.slice("--config=".length);
50
+ return 0;
51
+ }
52
+ if (argument === "--baseline") {
53
+ options.baselinePath = argv[index + 1];
54
+ return 1;
55
+ }
56
+ if (argument.startsWith("--baseline=")) {
57
+ options.baselinePath = argument.slice("--baseline=".length);
58
+ return 0;
59
+ }
60
+ if (argument === "--ignore") {
61
+ const value = argv[index + 1];
62
+ if (value !== undefined) {
63
+ options.ignores.push(value);
64
+ }
65
+ return 1;
66
+ }
67
+ if (argument.startsWith("--ignore=")) {
68
+ options.ignores.push(argument.slice("--ignore=".length));
69
+ return 0;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function parseArguments(argv: string[]): Options {
75
+ const options: Options = {
76
+ baselinePath: undefined,
77
+ configPath: undefined,
78
+ ignores: [],
79
+ json: false,
80
+ targets: [],
81
+ update: false,
82
+ };
83
+ for (let index = 0; index < argv.length; index += 1) {
84
+ const argument = argv[index] ?? "";
85
+ const consumed = applyValueOption(options, argument, argv, index);
86
+ if (consumed !== null) {
87
+ index += consumed;
88
+ continue;
89
+ }
90
+ if (applyFlagOption(options, argument)) {
91
+ continue;
92
+ }
93
+ if (argument.startsWith("-")) {
94
+ throw new Error(`unknown option: ${argument}`);
95
+ }
96
+ options.targets.push(argument);
97
+ }
98
+ return options;
99
+ }
100
+
101
+ function mergeIgnores(config: QualityConfig, ignores: string[]): QualityConfig {
102
+ if (ignores.length === 0) {
103
+ return config;
104
+ }
105
+ return { ...config, ignore: [...(config.ignore ?? []), ...ignores] };
106
+ }
107
+
108
+ function resolveBaselinePath(
109
+ options: Options,
110
+ config: QualityConfig,
111
+ cwd: string,
112
+ ): string | null {
113
+ if (options.baselinePath !== undefined) {
114
+ return resolve(cwd, options.baselinePath);
115
+ }
116
+ if (config.baseline === false) {
117
+ return null;
118
+ }
119
+ return resolve(cwd, config.baseline ?? DEFAULT_BASELINE_FILE);
120
+ }
121
+
122
+ function resolveConfigPath(options: Options, cwd: string): string | null {
123
+ if (options.configPath !== undefined) {
124
+ return resolve(cwd, options.configPath);
125
+ }
126
+ return findConfigFile(cwd);
127
+ }
128
+
129
+ async function main(argv: string[]): Promise<number> {
130
+ if (argv.includes("--help") || argv.includes("-h")) {
131
+ console.log(USAGE);
132
+ return 0;
133
+ }
134
+ const options = parseArguments(argv);
135
+ const cwd = process.cwd();
136
+ const configPath = resolveConfigPath(options, cwd);
137
+ if (configPath === null) {
138
+ console.error(`quality.config.ts not found in ${cwd}`);
139
+ return 2;
140
+ }
141
+ const config = mergeIgnores(await loadConfig(configPath), options.ignores);
142
+ const baselinePath = resolveBaselinePath(options, config, cwd);
143
+ const results = await runEngines({
144
+ baseline:
145
+ options.update || baselinePath === null
146
+ ? null
147
+ : readBaseline(baselinePath),
148
+ config,
149
+ cwd,
150
+ rawBaseline: join(tmpdir(), `quality-check-raw-${process.pid}.json`),
151
+ targets:
152
+ options.targets.length > 0 ? options.targets : (config.targets ?? ["."]),
153
+ });
154
+ if (options.update) {
155
+ if (baselinePath === null) {
156
+ console.error("baseline is disabled by the configuration");
157
+ return 2;
158
+ }
159
+ const baseline = createBaseline(
160
+ results.flatMap((result) => result.detected),
161
+ );
162
+ writeBaseline(baselinePath, baseline);
163
+ console.log(
164
+ `Recorded ${baseline.entries.length} entries in ${baselinePath}`,
165
+ );
166
+ return 0;
167
+ }
168
+ if (options.json) {
169
+ console.log(JSON.stringify(toJsonReport(results), null, "\t"));
170
+ } else {
171
+ for (const result of results) {
172
+ console.log(formatEngineSection(result));
173
+ }
174
+ console.log(formatSummary(results));
175
+ }
176
+ return results.some((result) => result.status !== "passed") ? 1 : 0;
177
+ }
178
+
179
+ try {
180
+ process.exit(await main(process.argv.slice(2)));
181
+ } catch (error) {
182
+ console.error(error instanceof Error ? error.message : String(error));
183
+ process.exit(2);
184
+ }
package/src/config.ts ADDED
@@ -0,0 +1,201 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+
5
+ /**
6
+ * 統合CLIが起動できるengineの名前 並び順が実行順になる
7
+ */
8
+ export const ENGINE_NAMES = [
9
+ "biome",
10
+ "knip",
11
+ "comment-check",
12
+ "tsdoc-check",
13
+ ] as const;
14
+
15
+ /**
16
+ * engine名のunion型
17
+ */
18
+ export type EngineName = (typeof ENGINE_NAMES)[number];
19
+
20
+ /**
21
+ * engineのbinへ追加で渡す起動設定
22
+ */
23
+ export interface EngineOptions {
24
+ /** engineの既定引数の後ろへ足す引数 */
25
+ args?: string[];
26
+ }
27
+
28
+ /**
29
+ * quality.config.tsが受け付ける統合検査の設定
30
+ */
31
+ export interface QualityConfig {
32
+ /** 起動するengine 値がfalseまたは未指定のengineは起動しない */
33
+ engines: Partial<Record<EngineName, boolean | EngineOptions>>;
34
+ /** file走査engineが検査から外すpath */
35
+ ignore?: string[];
36
+ /** baseline fileのpath falseなら差分判定を行わない */
37
+ baseline?: string | false;
38
+ /** file走査engineへ渡す対象path */
39
+ targets?: string[];
40
+ }
41
+
42
+ /**
43
+ * 設定fileを型付けするための恒等関数
44
+ */
45
+ export function defineConfig(config: QualityConfig): QualityConfig {
46
+ return config;
47
+ }
48
+
49
+ /**
50
+ * config fileを探索する既定のfile名
51
+ */
52
+ export const DEFAULT_CONFIG_FILES = [
53
+ "quality.config.ts",
54
+ "quality.config.mts",
55
+ "quality.config.js",
56
+ "quality.config.mjs",
57
+ ] as const;
58
+
59
+ /**
60
+ * baseline fileの既定名
61
+ */
62
+ export const DEFAULT_BASELINE_FILE = "quality-baseline.json";
63
+
64
+ type JsonObject = Record<string, unknown>;
65
+
66
+ function isJsonObject(value: unknown): value is JsonObject {
67
+ return typeof value === "object" && value !== null && !Array.isArray(value);
68
+ }
69
+
70
+ function isEngineName(value: string): value is EngineName {
71
+ return (ENGINE_NAMES as readonly string[]).includes(value);
72
+ }
73
+
74
+ function readStringArray(value: unknown, field: string): string[] | undefined {
75
+ if (value === undefined) {
76
+ return undefined;
77
+ }
78
+ if (
79
+ !Array.isArray(value) ||
80
+ value.some((entry) => typeof entry !== "string" || entry === "")
81
+ ) {
82
+ throw new Error(`${field} must be an array of non-empty strings`);
83
+ }
84
+ return [...value] as string[];
85
+ }
86
+
87
+ function readBaseline(
88
+ value: unknown,
89
+ source: string,
90
+ ): string | false | undefined {
91
+ if (value === undefined || value === false) {
92
+ return value;
93
+ }
94
+ if (typeof value === "string" && value !== "") {
95
+ return value;
96
+ }
97
+ throw new Error(`${source}: baseline must be a path string or false`);
98
+ }
99
+
100
+ function parseEngineOptions(
101
+ value: unknown,
102
+ source: string,
103
+ name: EngineName,
104
+ ): boolean | EngineOptions {
105
+ if (typeof value === "boolean") {
106
+ return value;
107
+ }
108
+ if (!isJsonObject(value)) {
109
+ throw new Error(
110
+ `${source}: engines.${name} must be a boolean or an object`,
111
+ );
112
+ }
113
+ const unknown = Object.keys(value).filter((key) => key !== "args");
114
+ if (unknown.length > 0) {
115
+ throw new Error(
116
+ `${source}: engines.${name} has an unknown option: ${unknown[0]}`,
117
+ );
118
+ }
119
+ const args = readStringArray(value.args, `${source}: engines.${name}.args`);
120
+ return args === undefined ? {} : { args };
121
+ }
122
+
123
+ /**
124
+ * 読み込んだ設定を検証して不足分を補う
125
+ */
126
+ export function parseConfig(value: unknown, source: string): QualityConfig {
127
+ if (!isJsonObject(value)) {
128
+ throw new Error(`${source} must export a config object`);
129
+ }
130
+ if (!isJsonObject(value.engines)) {
131
+ throw new Error(`${source} must export an engines object`);
132
+ }
133
+ const engines: Partial<Record<EngineName, boolean | EngineOptions>> = {};
134
+ for (const [name, options] of Object.entries(value.engines)) {
135
+ if (!isEngineName(name)) {
136
+ throw new Error(`${source}: unknown engine: ${name}`);
137
+ }
138
+ engines[name] = parseEngineOptions(options, source, name);
139
+ }
140
+ if (ENGINE_NAMES.every((name) => !engines[name])) {
141
+ throw new Error(`${source} must enable at least one engine`);
142
+ }
143
+ const config: QualityConfig = { engines };
144
+ const baseline = readBaseline(value.baseline, source);
145
+ if (baseline !== undefined) {
146
+ config.baseline = baseline;
147
+ }
148
+ const ignore = readStringArray(value.ignore, `${source}: ignore`);
149
+ if (ignore !== undefined) {
150
+ config.ignore = ignore;
151
+ }
152
+ const targets = readStringArray(value.targets, `${source}: targets`);
153
+ if (targets !== undefined) {
154
+ config.targets = targets;
155
+ }
156
+ return config;
157
+ }
158
+
159
+ /**
160
+ * 有効なengineを実行順で返す
161
+ */
162
+ export function enabledEngines(config: QualityConfig): EngineName[] {
163
+ return ENGINE_NAMES.filter((name) => Boolean(config.engines[name]));
164
+ }
165
+
166
+ /**
167
+ * engineの起動設定を返す 無効なengineにはnullを返す
168
+ */
169
+ export function engineOptions(
170
+ config: QualityConfig,
171
+ name: EngineName,
172
+ ): EngineOptions | null {
173
+ const value = config.engines[name];
174
+ if (!value) {
175
+ return null;
176
+ }
177
+ return value === true ? {} : value;
178
+ }
179
+
180
+ /**
181
+ * 作業ディレクトリからconfig fileを探す
182
+ */
183
+ export function findConfigFile(cwd: string): string | null {
184
+ for (const name of DEFAULT_CONFIG_FILES) {
185
+ const candidate = resolve(cwd, name);
186
+ if (existsSync(candidate)) {
187
+ return candidate;
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+
193
+ /**
194
+ * config fileを読み込んで検証する
195
+ */
196
+ export async function loadConfig(path: string): Promise<QualityConfig> {
197
+ const module: unknown = await import(pathToFileURL(path).href);
198
+ const value =
199
+ isJsonObject(module) && "default" in module ? module.default : module;
200
+ return parseConfig(value, path);
201
+ }
package/src/engines.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { type EngineName, engineOptions, type QualityConfig } from "./config";
4
+
5
+ /**
6
+ * 子プロセスとして起動したengineの生の結果
7
+ */
8
+ export interface EngineProcessResult {
9
+ exitCode: number | null;
10
+ stderr: string;
11
+ stdout: string;
12
+ }
13
+
14
+ /**
15
+ * engineを起動して出力を返す関数 テストでは差し替える
16
+ */
17
+ export type EngineRunner = (
18
+ command: string[],
19
+ options: { cwd: string },
20
+ ) => Promise<EngineProcessResult>;
21
+
22
+ /**
23
+ * engineごとの実行file名
24
+ */
25
+ export const ENGINE_BINS: Record<EngineName, string> = {
26
+ biome: "biome",
27
+ "comment-check": "comment-check",
28
+ knip: "knip",
29
+ "tsdoc-check": "tsdoc-check",
30
+ };
31
+
32
+ const WINDOWS_SHIMS = [".exe", ".cmd", ".bat", ""];
33
+ const POSIX_SHIMS = [""];
34
+
35
+ /**
36
+ * engineのコマンドを組み立てるための実行条件
37
+ */
38
+ export interface EngineCommandContext {
39
+ config: QualityConfig;
40
+ ignores: string[];
41
+ /** comment-checkのbaseline差分を無効化するために渡す未作成のpath */
42
+ rawBaseline: string;
43
+ targets: string[];
44
+ }
45
+
46
+ /**
47
+ * 検出をJSONで返すengineか
48
+ */
49
+ export function isFindingEngine(name: EngineName): boolean {
50
+ return name === "comment-check" || name === "tsdoc-check";
51
+ }
52
+
53
+ /**
54
+ * node_modules/.binとPATHからengineの実行fileを探す
55
+ */
56
+ export function resolveExecutable(
57
+ name: EngineName,
58
+ cwd: string,
59
+ ): string | null {
60
+ const shims = process.platform === "win32" ? WINDOWS_SHIMS : POSIX_SHIMS;
61
+ const bin = ENGINE_BINS[name];
62
+ let directory = cwd;
63
+ for (;;) {
64
+ for (const shim of shims) {
65
+ const candidate = join(
66
+ directory,
67
+ "node_modules",
68
+ ".bin",
69
+ `${bin}${shim}`,
70
+ );
71
+ if (existsSync(candidate)) {
72
+ return candidate;
73
+ }
74
+ }
75
+ const parent = dirname(directory);
76
+ if (parent === directory) {
77
+ break;
78
+ }
79
+ directory = parent;
80
+ }
81
+ const onPath = Bun.which(bin);
82
+ return onPath ?? null;
83
+ }
84
+
85
+ /**
86
+ * engineへ渡すコマンドを組み立てる
87
+ */
88
+ export function buildEngineCommand(
89
+ name: EngineName,
90
+ executable: string,
91
+ context: EngineCommandContext,
92
+ ): string[] {
93
+ const extra = engineOptions(context.config, name)?.args ?? [];
94
+ if (name === "biome") {
95
+ return [executable, "check", ...context.targets, ...extra];
96
+ }
97
+ if (name === "knip") {
98
+ return [executable, ...extra];
99
+ }
100
+ const ignoreArguments = context.ignores.flatMap((ignore) => [
101
+ "--ignore",
102
+ ignore,
103
+ ]);
104
+ if (name === "comment-check") {
105
+ return [
106
+ executable,
107
+ "--json",
108
+ "--baseline",
109
+ context.rawBaseline,
110
+ ...context.targets,
111
+ ...ignoreArguments,
112
+ ...extra,
113
+ ];
114
+ }
115
+ return [
116
+ executable,
117
+ "--json",
118
+ ...context.targets,
119
+ ...ignoreArguments,
120
+ ...extra,
121
+ ];
122
+ }
123
+
124
+ /**
125
+ * Bun.spawnでengineを起動する既定のrunner
126
+ */
127
+ export async function runEngineProcess(
128
+ command: string[],
129
+ options: { cwd: string },
130
+ ): Promise<EngineProcessResult> {
131
+ const child = Bun.spawn({
132
+ cmd: command,
133
+ cwd: options.cwd,
134
+ stderr: "pipe",
135
+ stdout: "pipe",
136
+ });
137
+ const [stdout, stderr, exitCode] = await Promise.all([
138
+ new Response(child.stdout).text(),
139
+ new Response(child.stderr).text(),
140
+ child.exited,
141
+ ]);
142
+ return { exitCode, stderr, stdout };
143
+ }
@@ -0,0 +1,106 @@
1
+ import type { EngineName } from "./config";
2
+
3
+ /**
4
+ * 検出の重大度
5
+ */
6
+ export type FindingSeverity = "error" | "warning";
7
+
8
+ /**
9
+ * engine間の差を吸収した検出1件
10
+ */
11
+ export interface NormalizedFinding {
12
+ column: number;
13
+ engine: EngineName;
14
+ file: string;
15
+ line: number;
16
+ rule: string;
17
+ severity: FindingSeverity;
18
+ text: string;
19
+ }
20
+
21
+ /**
22
+ * engineが--jsonで返した検出の分類
23
+ */
24
+ export interface ParsedFindings {
25
+ errors: NormalizedFinding[];
26
+ warnings: NormalizedFinding[];
27
+ }
28
+
29
+ type JsonObject = Record<string, unknown>;
30
+
31
+ function isJsonObject(value: unknown): value is JsonObject {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ function readFinding(
36
+ engine: EngineName,
37
+ value: unknown,
38
+ severity: FindingSeverity,
39
+ textField: "message" | "text",
40
+ ): NormalizedFinding {
41
+ if (!isJsonObject(value)) {
42
+ throw new Error(`${engine} printed an unexpected finding`);
43
+ }
44
+ const { column, file, line, rule } = value;
45
+ const text = value[textField];
46
+ if (
47
+ typeof rule !== "string" ||
48
+ typeof file !== "string" ||
49
+ typeof line !== "number" ||
50
+ typeof column !== "number" ||
51
+ typeof text !== "string"
52
+ ) {
53
+ throw new Error(`${engine} printed an unexpected finding`);
54
+ }
55
+ return { column, engine, file, line, rule, severity, text };
56
+ }
57
+
58
+ function readEntries(
59
+ engine: EngineName,
60
+ value: unknown,
61
+ field: string,
62
+ ): unknown[] {
63
+ if (!Array.isArray(value)) {
64
+ throw new Error(`${engine} printed JSON without a ${field} array`);
65
+ }
66
+ return value;
67
+ }
68
+
69
+ function parseJson(engine: EngineName, stdout: string): JsonObject {
70
+ let value: unknown;
71
+ try {
72
+ value = JSON.parse(stdout);
73
+ } catch {
74
+ throw new Error(`${engine} did not print JSON`);
75
+ }
76
+ if (!isJsonObject(value)) {
77
+ throw new Error(`${engine} printed an unexpected JSON value`);
78
+ }
79
+ return value;
80
+ }
81
+
82
+ /**
83
+ * engineの--json出力を検出へ変換する 解析できない出力は例外にする
84
+ */
85
+ export function parseFindings(
86
+ engine: EngineName,
87
+ stdout: string,
88
+ ): ParsedFindings {
89
+ const value = parseJson(engine, stdout);
90
+ if (engine === "comment-check") {
91
+ return {
92
+ errors: readEntries(engine, value.added, "added").map((entry) =>
93
+ readFinding(engine, entry, "error", "text"),
94
+ ),
95
+ warnings: [],
96
+ };
97
+ }
98
+ return {
99
+ errors: readEntries(engine, value.errors, "errors").map((entry) =>
100
+ readFinding(engine, entry, "error", "message"),
101
+ ),
102
+ warnings: readEntries(engine, value.warnings, "warnings").map((entry) =>
103
+ readFinding(engine, entry, "warning", "message"),
104
+ ),
105
+ };
106
+ }
package/src/report.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { isFindingEngine } from "./engines";
2
+ import type { NormalizedFinding } from "./findings";
3
+ import type { EngineResult } from "./run";
4
+
5
+ function describeFinding(finding: NormalizedFinding): string {
6
+ return `${finding.file}:${finding.line}:${finding.column} ${finding.rule} ${finding.severity} ${finding.text}`;
7
+ }
8
+
9
+ function describeCounts(result: EngineResult): string {
10
+ if (!isFindingEngine(result.name)) {
11
+ return `exit ${result.exitCode}`;
12
+ }
13
+ return `${result.reported.length} new, ${result.resolved} resolved, ${result.warnings.length} warnings`;
14
+ }
15
+
16
+ function describeStatus(result: EngineResult): string {
17
+ if (result.status === "error") {
18
+ return "error";
19
+ }
20
+ return `${result.status} (${describeCounts(result)})`;
21
+ }
22
+
23
+ /**
24
+ * engine1つ分の出力sectionを組み立てる
25
+ */
26
+ export function formatEngineSection(result: EngineResult): string {
27
+ const lines = [`== ${result.name} ==`];
28
+ if (result.output !== "" && !isFindingEngine(result.name)) {
29
+ lines.push(result.output);
30
+ }
31
+ for (const finding of [...result.reported, ...result.warnings]) {
32
+ lines.push(describeFinding(finding));
33
+ }
34
+ if (result.message !== undefined) {
35
+ lines.push(result.message);
36
+ }
37
+ lines.push(`${result.name}: ${describeStatus(result)}`);
38
+ return lines.join("\n");
39
+ }
40
+
41
+ /**
42
+ * 失敗したengineを列挙した集約summaryを組み立てる
43
+ */
44
+ export function formatSummary(results: EngineResult[]): string {
45
+ const failed = results
46
+ .filter((result) => result.status !== "passed")
47
+ .map((result) => result.name);
48
+ const passed = results
49
+ .filter((result) => result.status === "passed")
50
+ .map((result) => result.name);
51
+ if (failed.length === 0) {
52
+ return `quality-check: ${results.length} engines passed`;
53
+ }
54
+ const lines = [
55
+ `quality-check: ${failed.length} of ${results.length} engines failed`,
56
+ ` failed: ${failed.join(", ")}`,
57
+ ];
58
+ if (passed.length > 0) {
59
+ lines.push(` passed: ${passed.join(", ")}`);
60
+ }
61
+ return lines.join("\n");
62
+ }
63
+
64
+ /**
65
+ * engineの結果をJSONへ変換する
66
+ */
67
+ export function toJsonReport(results: EngineResult[]): unknown {
68
+ return {
69
+ engines: results.map((result) => ({
70
+ detected: result.detected.length,
71
+ exitCode: result.exitCode,
72
+ message: result.message ?? null,
73
+ name: result.name,
74
+ reported: result.reported,
75
+ resolved: result.resolved,
76
+ status: result.status,
77
+ warnings: result.warnings,
78
+ })),
79
+ failed: results
80
+ .filter((result) => result.status !== "passed")
81
+ .map((result) => result.name),
82
+ };
83
+ }
package/src/run.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { type BaselineFile, compareWithBaseline } from "./baseline";
2
+ import { type EngineName, enabledEngines, type QualityConfig } from "./config";
3
+ import {
4
+ buildEngineCommand,
5
+ ENGINE_BINS,
6
+ type EngineCommandContext,
7
+ type EngineProcessResult,
8
+ type EngineRunner,
9
+ isFindingEngine,
10
+ resolveExecutable,
11
+ runEngineProcess,
12
+ } from "./engines";
13
+ import { type NormalizedFinding, parseFindings } from "./findings";
14
+
15
+ /**
16
+ * engine1つ分の実行状態
17
+ */
18
+ export type EngineStatus = "error" | "failed" | "passed";
19
+
20
+ /**
21
+ * engine1つ分の実行結果
22
+ */
23
+ export interface EngineResult {
24
+ /** baseline適用前の阻害する検出 */
25
+ detected: NormalizedFinding[];
26
+ exitCode: number | null;
27
+ message?: string;
28
+ name: EngineName;
29
+ output: string;
30
+ /** baseline適用後に残った阻害する検出 */
31
+ reported: NormalizedFinding[];
32
+ resolved: number;
33
+ status: EngineStatus;
34
+ warnings: NormalizedFinding[];
35
+ }
36
+
37
+ /**
38
+ * engineをまとめて起動するための実行条件
39
+ */
40
+ export interface RunOptions {
41
+ /** 適用するbaseline nullなら差分判定を行わない */
42
+ baseline: BaselineFile | null;
43
+ config: QualityConfig;
44
+ cwd: string;
45
+ /** file走査engineへ渡す対象path */
46
+ targets: string[];
47
+ /** comment-checkのbaseline差分を無効化する未作成のpath */
48
+ rawBaseline: string;
49
+ /** engineの実行fileを解決する関数 テストでは差し替える */
50
+ resolve?: (name: EngineName, cwd: string) => string | null;
51
+ runner?: EngineRunner;
52
+ }
53
+
54
+ function baseResult(
55
+ name: EngineName,
56
+ exitCode: number | null,
57
+ output: string,
58
+ ): Omit<EngineResult, "status"> {
59
+ return {
60
+ detected: [],
61
+ exitCode,
62
+ name,
63
+ output,
64
+ reported: [],
65
+ resolved: 0,
66
+ warnings: [],
67
+ };
68
+ }
69
+
70
+ function joinOutput(result: EngineProcessResult): string {
71
+ return [result.stdout, result.stderr]
72
+ .map((part) => part.trimEnd())
73
+ .filter((part) => part !== "")
74
+ .join("\n");
75
+ }
76
+
77
+ function describeError(error: unknown): string {
78
+ return error instanceof Error ? error.message : String(error);
79
+ }
80
+
81
+ async function runFindingEngine(
82
+ name: EngineName,
83
+ options: RunOptions,
84
+ executable: string,
85
+ context: EngineCommandContext,
86
+ runner: EngineRunner,
87
+ ): Promise<EngineResult> {
88
+ const result = await runner(buildEngineCommand(name, executable, context), {
89
+ cwd: options.cwd,
90
+ });
91
+ const output = joinOutput(result);
92
+ if (result.exitCode === 2) {
93
+ return {
94
+ ...baseResult(name, 2, output),
95
+ message: `${name} could not finish`,
96
+ status: "error",
97
+ };
98
+ }
99
+ let parsed: ReturnType<typeof parseFindings>;
100
+ try {
101
+ parsed = parseFindings(name, result.stdout);
102
+ } catch (error) {
103
+ return {
104
+ ...baseResult(name, result.exitCode, output),
105
+ message: describeError(error),
106
+ status: "error",
107
+ };
108
+ }
109
+ const comparison =
110
+ options.baseline === null
111
+ ? { added: parsed.errors, resolved: [] }
112
+ : compareWithBaseline(parsed.errors, options.baseline);
113
+ return {
114
+ ...baseResult(name, result.exitCode, output),
115
+ detected: parsed.errors,
116
+ reported: comparison.added,
117
+ resolved: comparison.resolved.length,
118
+ status: comparison.added.length > 0 ? "failed" : "passed",
119
+ warnings: parsed.warnings,
120
+ };
121
+ }
122
+
123
+ async function runProcessEngine(
124
+ name: EngineName,
125
+ options: RunOptions,
126
+ executable: string,
127
+ context: EngineCommandContext,
128
+ runner: EngineRunner,
129
+ ): Promise<EngineResult> {
130
+ const result = await runner(buildEngineCommand(name, executable, context), {
131
+ cwd: options.cwd,
132
+ });
133
+ const output = joinOutput(result);
134
+ if (result.exitCode === 2) {
135
+ return {
136
+ ...baseResult(name, 2, output),
137
+ message: `${name} could not finish`,
138
+ status: "error",
139
+ };
140
+ }
141
+ return {
142
+ ...baseResult(name, result.exitCode, output),
143
+ status: result.exitCode === 0 ? "passed" : "failed",
144
+ };
145
+ }
146
+
147
+ async function runEngine(
148
+ name: EngineName,
149
+ options: RunOptions,
150
+ context: EngineCommandContext,
151
+ runner: EngineRunner,
152
+ ): Promise<EngineResult> {
153
+ const executable = (options.resolve ?? resolveExecutable)(name, options.cwd);
154
+ if (executable === null) {
155
+ return {
156
+ ...baseResult(name, null, ""),
157
+ message: `${ENGINE_BINS[name]} is not installed`,
158
+ status: "error",
159
+ };
160
+ }
161
+ if (isFindingEngine(name)) {
162
+ return await runFindingEngine(name, options, executable, context, runner);
163
+ }
164
+ return await runProcessEngine(name, options, executable, context, runner);
165
+ }
166
+
167
+ /**
168
+ * 設定で有効なengineを順に起動する
169
+ */
170
+ export async function runEngines(options: RunOptions): Promise<EngineResult[]> {
171
+ const runner = options.runner ?? runEngineProcess;
172
+ const context: EngineCommandContext = {
173
+ config: options.config,
174
+ ignores: options.config.ignore ?? [],
175
+ rawBaseline: options.rawBaseline,
176
+ targets: options.targets,
177
+ };
178
+ const results: EngineResult[] = [];
179
+ for (const name of enabledEngines(options.config)) {
180
+ results.push(await runEngine(name, options, context, runner));
181
+ }
182
+ return results;
183
+ }