@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 +87 -0
- package/README.md +97 -0
- package/package.json +27 -0
- package/src/baseline.ts +147 -0
- package/src/cli.ts +184 -0
- package/src/config.ts +201 -0
- package/src/engines.ts +143 -0
- package/src/findings.ts +106 -0
- package/src/report.ts +83 -0
- package/src/run.ts +183 -0
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
|
+
}
|
package/src/baseline.ts
ADDED
|
@@ -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
|
+
}
|
package/src/findings.ts
ADDED
|
@@ -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
|
+
}
|