skills-doctor 0.3.0 → 0.4.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/CHANGELOG.md +51 -1
- package/README.md +26 -10
- package/bin/skills-doctor.js +2 -1
- package/dist/cli/commands/scan.d.ts +2 -0
- package/dist/cli/commands/scan.d.ts.map +1 -1
- package/dist/cli/commands/scan.js +104 -45
- package/dist/cli/index.d.ts +5 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +38 -3
- package/dist/cli/utils/handoff-to-agent.d.ts.map +1 -1
- package/dist/cli/utils/handoff-to-agent.js +26 -10
- package/dist/domain/build-handoff-prompt.d.ts.map +1 -1
- package/dist/domain/build-handoff-prompt.js +6 -9
- package/dist/domain/build-report.d.ts.map +1 -1
- package/dist/domain/build-report.js +8 -2
- package/dist/domain/calculate-score.d.ts +4 -1
- package/dist/domain/calculate-score.d.ts.map +1 -1
- package/dist/domain/calculate-score.js +5 -1
- package/dist/domain/group-findings.d.ts +8 -0
- package/dist/domain/group-findings.d.ts.map +1 -0
- package/dist/domain/group-findings.js +30 -0
- package/dist/domain/parse-skill.d.ts.map +1 -1
- package/dist/domain/parse-skill.js +9 -6
- package/dist/domain/rule-catalog.d.ts +199 -0
- package/dist/domain/rule-catalog.d.ts.map +1 -0
- package/dist/domain/rule-catalog.js +230 -0
- package/dist/domain/rules/quality.d.ts +7 -1
- package/dist/domain/rules/quality.d.ts.map +1 -1
- package/dist/domain/rules/quality.js +103 -19
- package/dist/domain/scan-skills.d.ts +2 -1
- package/dist/domain/scan-skills.d.ts.map +1 -1
- package/dist/domain/scan-skills.js +101 -25
- package/dist/domain/summarize-findings.d.ts +6 -1
- package/dist/domain/summarize-findings.d.ts.map +1 -1
- package/dist/domain/summarize-findings.js +18 -3
- package/dist/domain/write-findings-directory.d.ts.map +1 -1
- package/dist/domain/write-findings-directory.js +13 -11
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/package.json +5 -4
- package/skills/skills-doctor/SKILL.md +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## [0.4.0] - 2026-06-18
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Exposed the rule catalog as structured public API metadata.
|
|
14
|
+
- Added opt-in `--fail-on` and `--min-score` scan quality gates for automation.
|
|
15
|
+
- Included the agent-facing `skills/skills-doctor` wrapper in the npm package.
|
|
16
|
+
- Added injectable resource and eval existence checks for `validateQualityRules`.
|
|
17
|
+
- Added a public API and `schemaVersion: 1` report schema reference.
|
|
18
|
+
- Tested the declared Node runtime floor in CI and aligned the package engine range to Node 22.13+.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Made non-interactive scans fail on ambiguous local/global or Claude/Codex root choices instead of scanning all detected roots.
|
|
23
|
+
- Added bounded concurrent `SKILL.md` reads while preserving deterministic scan order.
|
|
24
|
+
- Reused shared grouped-finding indexes for report summaries and grouped output.
|
|
25
|
+
- Made the manual release checklist version-aware and aligned with tag-derived release notes.
|
|
26
|
+
- Replaced the stale reusable CLI spec with a Skills Doctor-specific architecture map.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- Restored the documented `missing-skill` and `invalid-frontmatter` rule behavior so the structured rule catalog matches scanner findings.
|
|
31
|
+
- Stopped trailing sentence punctuation from creating false missing-resource warnings for valid skill resource references.
|
|
32
|
+
- Wrote repair handoff reports and prompts before stopping when no local `claude` or `codex` agent is available.
|
|
33
|
+
- Recognized normal `--help` mentions when checking script help guidance.
|
|
34
|
+
- Prevented long per-skill handoff report filenames from colliding after truncation.
|
|
35
|
+
- Returned JSON error reports for parse-level CLI failures when `--json` is set.
|
|
36
|
+
- Made the local `bun run dev` script execute the same CLI bin path used by packaged runs.
|
|
37
|
+
- Rejected symlinked skill resource references that resolve outside the skill directory.
|
|
38
|
+
- Preserved custom-root discovery diagnostics in scan reports when interactive scans add a missing custom skills path.
|
|
39
|
+
- Reflected blocking diagnostic failures in scan scores so diagnostic-only failures no longer report a perfect score.
|
|
40
|
+
- Reported unreadable direct-child `SKILL.md` entries as blocking diagnostics while continuing to scan other skills.
|
|
41
|
+
- Kept repair handoff prompts available inline when writing `handoff-prompt.md` fails after report-directory creation.
|
|
42
|
+
- Kept the release workflow on token-backed `bun publish` until npm trusted publishing is configured for the package.
|
|
43
|
+
|
|
44
|
+
### Tests
|
|
45
|
+
|
|
46
|
+
- Tightened rule-catalog synchronization coverage so docs-only, catalog-only, and emitted-rule drift fail together.
|
|
47
|
+
- Covered script help guidance warnings for existing script references.
|
|
48
|
+
- Added packaged CLI smoke coverage for JSON stdout and blocking-scan exit behavior.
|
|
49
|
+
|
|
50
|
+
## [0.3.1] - 2026-06-16
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- Added line numbers to quality-rule findings where a specific source line can be resolved.
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- Hid repair handoff subset options that do not match any findings (for warning-only and advice-only scans).
|
|
59
|
+
- Made CLI module import safe by removing side-effect execution and routing runtime entry through `bin/skills-doctor.js`.
|
|
10
60
|
|
|
11
61
|
## [0.3.0] - 2026-06-16
|
|
12
62
|
|
package/README.md
CHANGED
|
@@ -40,6 +40,8 @@ Use the `skills-doctor` skill when people ask for an agent workflow to:
|
|
|
40
40
|
|
|
41
41
|
The CLI is the primary source of rule logic and output shape.
|
|
42
42
|
The skill wrapper is a convenience entrypoint so agents can discover and invoke the same scanner from within skill workflows.
|
|
43
|
+
When installed from npm, the wrapper is shipped at `node_modules/skills-doctor/skills/skills-doctor/SKILL.md`.
|
|
44
|
+
Copy or reference that file from an agent skill directory when you want an agent to discover the workflow, but keep scans delegated to `bunx skills-doctor@latest` or the installed `skills-doctor` binary.
|
|
43
45
|
|
|
44
46
|
## What It Scans
|
|
45
47
|
|
|
@@ -59,8 +61,9 @@ to scan local project skills, global/root skills, or both. When both Claude and
|
|
|
59
61
|
Codex/agents roots exist in the selected scope, it asks whether to scan Claude,
|
|
60
62
|
Codex/agents, or both. If you already have standard roots detected, it also lets
|
|
61
63
|
you add a custom skills directory path in the same interactive flow. Non-interactive
|
|
62
|
-
runs
|
|
63
|
-
|
|
64
|
+
runs scan only a single unambiguous detected root. If multiple local/global
|
|
65
|
+
scopes or multiple ecosystems are detected, they fail with a clear user error
|
|
66
|
+
instead of guessing.
|
|
64
67
|
|
|
65
68
|
## What It Checks
|
|
66
69
|
|
|
@@ -77,15 +80,17 @@ the Agent Skills standards from <https://agentskills.io/home>, including:
|
|
|
77
80
|
- divergent same-name skills across Claude and Codex/agents roots
|
|
78
81
|
|
|
79
82
|
See `docs/RULES.md` for a full rule catalog, severity, and intended rationale.
|
|
83
|
+
Programmatic consumers can import `ruleCatalog` for the same metadata as structured data.
|
|
80
84
|
|
|
81
85
|
Findings are grouped as blocking errors, warnings, and advisory improvements.
|
|
82
86
|
The human summary opens with a score header showing a face, `0` to `100` score,
|
|
83
87
|
label, and proportional terminal bar. The score starts at 100 and deducts 1.5
|
|
84
88
|
points for each distinct error rule and 0.75 points for each distinct warning
|
|
85
|
-
rule;
|
|
86
|
-
findings
|
|
87
|
-
|
|
88
|
-
50
|
|
89
|
+
rule; each distinct blocking diagnostic code is scored like an error rule.
|
|
90
|
+
Repeated findings from the same rule do not increase the penalty. Advisory
|
|
91
|
+
findings and warning diagnostics are counted in the report but do not affect the
|
|
92
|
+
score. Score labels are `Great` for 75 or higher, `Needs work` for 50 through
|
|
93
|
+
74, and `Critical` below 50.
|
|
89
94
|
|
|
90
95
|
## Interactive Repair Flow
|
|
91
96
|
|
|
@@ -119,10 +124,14 @@ Use JSON output for automation:
|
|
|
119
124
|
skills-doctor --json
|
|
120
125
|
skills-doctor --json --json-compact
|
|
121
126
|
skills-doctor --yes --json
|
|
127
|
+
skills-doctor --yes --json --fail-on warning
|
|
128
|
+
skills-doctor --yes --json --min-score 95
|
|
122
129
|
```
|
|
123
130
|
|
|
124
131
|
JSON mode writes one machine-readable report to stdout and suppresses prompts
|
|
125
132
|
and spinners. Human logs and expected errors stay out of stdout.
|
|
133
|
+
By default, the exit code fails only for blocking errors and error diagnostics.
|
|
134
|
+
Use `--fail-on warning`, `--fail-on advice`, or `--min-score <number>` for stricter CI gates.
|
|
126
135
|
|
|
127
136
|
## Programmatic API
|
|
128
137
|
|
|
@@ -143,6 +152,7 @@ const report = buildScanReport({
|
|
|
143
152
|
```
|
|
144
153
|
|
|
145
154
|
The CLI remains the primary interface for interactive repair workflows.
|
|
155
|
+
See `docs/API.md` for supported exports and the `schemaVersion: 1` report schema.
|
|
146
156
|
|
|
147
157
|
## Exit Codes
|
|
148
158
|
|
|
@@ -162,15 +172,21 @@ agent then follows its own configuration.
|
|
|
162
172
|
Before tagging a release:
|
|
163
173
|
|
|
164
174
|
```bash
|
|
175
|
+
VERSION=<x.y.z>
|
|
165
176
|
bun run verify
|
|
166
177
|
bun run pack:dry-run
|
|
167
|
-
node scripts/extract-release-notes.mjs
|
|
178
|
+
node scripts/extract-release-notes.mjs "$VERSION"
|
|
168
179
|
```
|
|
169
180
|
|
|
170
181
|
Then:
|
|
171
182
|
|
|
172
183
|
1. Update `package.json` version.
|
|
173
184
|
2. Move changelog entries from `Unreleased` into `## [x.y.z] - YYYY-MM-DD`.
|
|
174
|
-
3.
|
|
175
|
-
4.
|
|
176
|
-
5.
|
|
185
|
+
3. Confirm `node scripts/extract-release-notes.mjs "$VERSION"` prints the intended notes.
|
|
186
|
+
4. Commit the release prep.
|
|
187
|
+
5. Tag `v<x.y.z>`.
|
|
188
|
+
6. Ensure the `NPM_TOKEN` repository secret can publish the npm package.
|
|
189
|
+
7. Push the tag to trigger the release workflow.
|
|
190
|
+
|
|
191
|
+
The release workflow derives the same version from the pushed tag with
|
|
192
|
+
`node scripts/extract-release-notes.mjs "${GITHUB_REF_NAME#v}"`.
|
package/bin/skills-doctor.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/scan.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;
|
|
1
|
+
{"version":3,"file":"scan.d.ts","sourceRoot":"","sources":["../../../src/cli/commands/scan.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAgB/D,OAAO,KAAK,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAM5F,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGhF,OAAO,EAAiB,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAEzE,MAAM,MAAM,SAAS,GAAG;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACrC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACjC,QAAQ,CAAC,UAAU,CAAC,EAAE,OAAO,CAAC;IAC9B,QAAQ,CAAC,OAAO,CAAC,EAAE,aAAa,CAAC;IACjC,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC;IACtC,QAAQ,CAAC,OAAO,CAAC,EAAE,cAAc,CAAC;IAClC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,QAAQ,CAAC,sBAAsB,CAAC,EAAE,sBAAsB,CAAC;IACzD,QAAQ,CAAC,sBAAsB,CAAC,EAAE,MAAM,CAAC;IACzC,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IACxC,QAAQ,CAAC,WAAW,CAAC,EAAE,mBAAmB,CAAC;CAC5C,CAAC;AAUF,eAAO,MAAM,UAAU,GACrB,WAAW,MAAM,EACjB,OAAO,SAAS,EAChB,UAAS,iBAAsB,KAC9B,OAAO,CAAC,UAAU,CAkHpB,CAAC"}
|
|
@@ -2,8 +2,9 @@ import path from "node:path";
|
|
|
2
2
|
import { buildScanReport } from "../../domain/build-report.js";
|
|
3
3
|
import { compareFindings, renderPostHandoffSummary } from "../../domain/compare-findings.js";
|
|
4
4
|
import { discoverSkillRoots } from "../../domain/discover-skill-roots.js";
|
|
5
|
+
import { groupFindingsByKey } from "../../domain/group-findings.js";
|
|
5
6
|
import { scanSkillRoots } from "../../domain/scan-skills.js";
|
|
6
|
-
import { renderHumanSummary, resolveScanExitCode } from "../../domain/summarize-findings.js";
|
|
7
|
+
import { renderHumanSummary, resolveScanExitCode, } from "../../domain/summarize-findings.js";
|
|
7
8
|
import { CliInputError } from "../utils/handle-error.js";
|
|
8
9
|
import { prepareRepairHandoff } from "../utils/handoff-to-agent.js";
|
|
9
10
|
import { enableJsonMode, writeJsonReport } from "../utils/json-mode.js";
|
|
@@ -14,6 +15,7 @@ import { shouldSkipPrompts } from "../utils/should-skip-prompts.js";
|
|
|
14
15
|
import { createSpinner } from "../utils/spinner.js";
|
|
15
16
|
export const scanAction = async (directory, flags, options = {}) => {
|
|
16
17
|
const cwd = path.resolve(options.cwd ?? process.cwd(), directory);
|
|
18
|
+
const gateOptions = resolveGateOptions(flags);
|
|
17
19
|
const prompts = options.prompts ?? inquirerPromptAdapter;
|
|
18
20
|
const writeStdout = options.writeStdout ?? ((message) => process.stdout.write(message));
|
|
19
21
|
const writeStderr = options.writeStderr ?? ((message) => process.stderr.write(message));
|
|
@@ -35,36 +37,46 @@ export const scanAction = async (directory, flags, options = {}) => {
|
|
|
35
37
|
}
|
|
36
38
|
const discovered = await spinner.run("Finding local skill roots...", () => discoverSkillRoots({ cwd, homeDir: options.homeDir }));
|
|
37
39
|
let roots = discovered.roots;
|
|
40
|
+
const diagnostics = [...discovered.diagnostics];
|
|
38
41
|
if (roots.length === 0) {
|
|
39
42
|
if (skipPrompts) {
|
|
40
43
|
throw new CliInputError("No .claude/skills or .agents/skills root was found. Re-run interactively or add a supported skills root.");
|
|
41
44
|
}
|
|
42
|
-
|
|
45
|
+
const selection = await selectCustomRoot({
|
|
43
46
|
cwd,
|
|
44
47
|
homeDir: options.homeDir,
|
|
45
48
|
prompts,
|
|
46
49
|
roots,
|
|
47
50
|
});
|
|
51
|
+
roots = selection.roots;
|
|
52
|
+
diagnostics.push(...selection.diagnostics);
|
|
53
|
+
}
|
|
54
|
+
else if (skipPrompts) {
|
|
55
|
+
assertNonInteractiveRootSelectionIsUnambiguous(roots);
|
|
48
56
|
}
|
|
49
57
|
else if (!skipPrompts) {
|
|
50
|
-
|
|
58
|
+
const scopeSelection = await selectRootScopes({
|
|
51
59
|
roots,
|
|
52
60
|
prompts,
|
|
53
61
|
cwd,
|
|
54
62
|
homeDir: options.homeDir,
|
|
55
63
|
});
|
|
56
|
-
roots =
|
|
64
|
+
roots = scopeSelection.roots;
|
|
65
|
+
diagnostics.push(...scopeSelection.diagnostics);
|
|
66
|
+
const rootSelection = await selectRoots({
|
|
57
67
|
roots,
|
|
58
68
|
prompts,
|
|
59
69
|
cwd,
|
|
60
70
|
homeDir: options.homeDir,
|
|
61
71
|
});
|
|
72
|
+
roots = rootSelection.roots;
|
|
73
|
+
diagnostics.push(...rootSelection.diagnostics);
|
|
62
74
|
}
|
|
63
75
|
if (roots.length === 0) {
|
|
64
76
|
throw new CliInputError("No readable skills root was selected.");
|
|
65
77
|
}
|
|
66
78
|
const startedAt = now();
|
|
67
|
-
const scan = await spinner.run("Scanning skills...", () => scanSkillRoots({ roots }));
|
|
79
|
+
const scan = await spinner.run("Scanning skills...", () => scanSkillRoots({ roots, diagnostics }));
|
|
68
80
|
const elapsedMilliseconds = Math.max(0, Math.round(now() - startedAt));
|
|
69
81
|
const report = buildScanReport({
|
|
70
82
|
version: options.version ?? "0.0.0",
|
|
@@ -102,9 +114,40 @@ export const scanAction = async (directory, flags, options = {}) => {
|
|
|
102
114
|
})) ?? report;
|
|
103
115
|
}
|
|
104
116
|
}
|
|
105
|
-
process.exitCode = resolveScanExitCode(finalReport);
|
|
117
|
+
process.exitCode = resolveScanExitCode(finalReport, gateOptions);
|
|
106
118
|
return finalReport;
|
|
107
119
|
};
|
|
120
|
+
const resolveGateOptions = (flags) => ({
|
|
121
|
+
failOn: parseFailOnSeverity(flags.failOn),
|
|
122
|
+
minScore: parseMinScore(flags.minScore),
|
|
123
|
+
});
|
|
124
|
+
const parseFailOnSeverity = (value) => {
|
|
125
|
+
if (value === undefined)
|
|
126
|
+
return undefined;
|
|
127
|
+
if (value === "error" || value === "warning" || value === "advice")
|
|
128
|
+
return value;
|
|
129
|
+
throw new CliInputError("Invalid --fail-on value. Use one of: error, warning, advice.");
|
|
130
|
+
};
|
|
131
|
+
const parseMinScore = (value) => {
|
|
132
|
+
if (value === undefined)
|
|
133
|
+
return undefined;
|
|
134
|
+
const score = Number(value);
|
|
135
|
+
if (!Number.isFinite(score) || score < 0 || score > 100) {
|
|
136
|
+
throw new CliInputError("Invalid --min-score value. Use a number from 0 to 100.");
|
|
137
|
+
}
|
|
138
|
+
return score;
|
|
139
|
+
};
|
|
140
|
+
const assertNonInteractiveRootSelectionIsUnambiguous = (roots) => {
|
|
141
|
+
const standardRoots = roots.filter((root) => root.source !== "custom");
|
|
142
|
+
const standardSources = new Set(standardRoots.map((root) => root.source));
|
|
143
|
+
if (standardSources.has("local") && standardSources.has("global")) {
|
|
144
|
+
throw new CliInputError("Multiple local and global skills roots were found. Re-run interactively to choose which scope to scan.");
|
|
145
|
+
}
|
|
146
|
+
const standardEcosystems = new Set(standardRoots.map((root) => root.ecosystem));
|
|
147
|
+
if (standardEcosystems.has("claude") && standardEcosystems.has("codex")) {
|
|
148
|
+
throw new CliInputError("Multiple Claude and Codex/agents skills roots were found. Re-run interactively to choose which ecosystem to scan.");
|
|
149
|
+
}
|
|
150
|
+
};
|
|
108
151
|
const selectRoots = async (input) => {
|
|
109
152
|
const { prompts, cwd, homeDir, roots } = input;
|
|
110
153
|
const customRoots = roots.filter((root) => root.source === "custom");
|
|
@@ -112,7 +155,7 @@ const selectRoots = async (input) => {
|
|
|
112
155
|
const hasClaude = standardRoots.some((root) => root.ecosystem === "claude");
|
|
113
156
|
const hasCodex = standardRoots.some((root) => root.ecosystem === "codex");
|
|
114
157
|
if (!hasClaude || !hasCodex)
|
|
115
|
-
return roots;
|
|
158
|
+
return { roots, diagnostics: [] };
|
|
116
159
|
const selection = await prompts.select("Choose skills folder to scan", [
|
|
117
160
|
{ name: "Both", value: "all" },
|
|
118
161
|
{ name: "Claude (.claude/skills)", value: "claude" },
|
|
@@ -120,7 +163,7 @@ const selectRoots = async (input) => {
|
|
|
120
163
|
{ name: "Add custom skills path", value: "custom" },
|
|
121
164
|
]);
|
|
122
165
|
if (selection === "all")
|
|
123
|
-
return roots;
|
|
166
|
+
return { roots, diagnostics: [] };
|
|
124
167
|
if (selection === "custom") {
|
|
125
168
|
return selectCustomRoot({
|
|
126
169
|
cwd,
|
|
@@ -129,7 +172,10 @@ const selectRoots = async (input) => {
|
|
|
129
172
|
roots,
|
|
130
173
|
});
|
|
131
174
|
}
|
|
132
|
-
return
|
|
175
|
+
return {
|
|
176
|
+
roots: [...standardRoots.filter((root) => root.ecosystem === selection), ...customRoots],
|
|
177
|
+
diagnostics: [],
|
|
178
|
+
};
|
|
133
179
|
};
|
|
134
180
|
const selectRootScopes = async (input) => {
|
|
135
181
|
const { prompts, cwd, homeDir, roots } = input;
|
|
@@ -137,7 +183,7 @@ const selectRootScopes = async (input) => {
|
|
|
137
183
|
const hasGlobal = roots.some((root) => root.source === "global");
|
|
138
184
|
const hasBothScopes = hasLocal && hasGlobal;
|
|
139
185
|
if (!hasLocal && !hasGlobal)
|
|
140
|
-
return roots;
|
|
186
|
+
return { roots, diagnostics: [] };
|
|
141
187
|
const allLabel = hasBothScopes
|
|
142
188
|
? "Both local project and global/root skills"
|
|
143
189
|
: "Detected skills root";
|
|
@@ -156,7 +202,7 @@ const selectRootScopes = async (input) => {
|
|
|
156
202
|
}
|
|
157
203
|
choices.push({ name: "Add custom skills path", value: "custom" });
|
|
158
204
|
if (choices.length <= 1) {
|
|
159
|
-
return roots;
|
|
205
|
+
return { roots, diagnostics: [] };
|
|
160
206
|
}
|
|
161
207
|
const selection = await prompts.select("Choose skills scope to scan", choices);
|
|
162
208
|
if (selection === "custom") {
|
|
@@ -168,17 +214,17 @@ const selectRootScopes = async (input) => {
|
|
|
168
214
|
});
|
|
169
215
|
}
|
|
170
216
|
if (selection === "all")
|
|
171
|
-
return roots;
|
|
217
|
+
return { roots, diagnostics: [] };
|
|
172
218
|
if (selection === "claude") {
|
|
173
|
-
return roots.filter((root) => root.ecosystem === "claude");
|
|
219
|
+
return { roots: roots.filter((root) => root.ecosystem === "claude"), diagnostics: [] };
|
|
174
220
|
}
|
|
175
221
|
if (selection === "codex") {
|
|
176
|
-
return roots.filter((root) => root.ecosystem === "codex");
|
|
222
|
+
return { roots: roots.filter((root) => root.ecosystem === "codex"), diagnostics: [] };
|
|
177
223
|
}
|
|
178
224
|
if (selection === "local" || selection === "global") {
|
|
179
|
-
return roots.filter((root) => root.source === selection);
|
|
225
|
+
return { roots: roots.filter((root) => root.source === selection), diagnostics: [] };
|
|
180
226
|
}
|
|
181
|
-
return roots;
|
|
227
|
+
return { roots, diagnostics: [] };
|
|
182
228
|
};
|
|
183
229
|
const selectCustomRoot = async (input) => {
|
|
184
230
|
const customRoot = await input.prompts.input("Skills directory path", ".");
|
|
@@ -187,7 +233,10 @@ const selectCustomRoot = async (input) => {
|
|
|
187
233
|
homeDir: input.homeDir,
|
|
188
234
|
customRoots: [{ rootPath: customRoot, ecosystem: "custom" }],
|
|
189
235
|
});
|
|
190
|
-
return
|
|
236
|
+
return {
|
|
237
|
+
roots: mergeRoots(input.roots, custom.roots),
|
|
238
|
+
diagnostics: custom.diagnostics,
|
|
239
|
+
};
|
|
191
240
|
};
|
|
192
241
|
const mergeRoots = (existingRoots, additionalRoots) => {
|
|
193
242
|
const merged = new Map();
|
|
@@ -228,34 +277,35 @@ const reviewFindings = async (report, input) => {
|
|
|
228
277
|
};
|
|
229
278
|
const runRepairAgentFlow = async (report, input) => {
|
|
230
279
|
try {
|
|
231
|
-
const agent = await chooseRepairAgent({
|
|
232
|
-
prompts: input.prompts,
|
|
233
|
-
isAvailable: input.isRepairAgentAvailable,
|
|
234
|
-
});
|
|
235
|
-
if (agent === undefined) {
|
|
236
|
-
input.write("Repair handoff cancelled.\n");
|
|
237
|
-
return undefined;
|
|
238
|
-
}
|
|
239
280
|
const handoff = await prepareRepairHandoff({
|
|
240
281
|
report,
|
|
241
282
|
prompts: input.prompts,
|
|
242
283
|
outputRoot: input.repairReportOutputRoot,
|
|
243
284
|
timestamp: input.repairReportTimestamp,
|
|
244
285
|
});
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
input.write(`Repair prompt: ${handoff.promptPath}\n`);
|
|
286
|
+
let agent;
|
|
287
|
+
try {
|
|
288
|
+
agent = await chooseRepairAgent({
|
|
289
|
+
prompts: input.prompts,
|
|
290
|
+
isAvailable: input.isRepairAgentAvailable,
|
|
291
|
+
});
|
|
252
292
|
}
|
|
253
|
-
|
|
254
|
-
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (error instanceof CliInputError) {
|
|
295
|
+
writeRepairHandoffSummary(handoff, input.write);
|
|
296
|
+
input.write(`${error.message}\n`);
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
throw error;
|
|
255
300
|
}
|
|
256
|
-
if (
|
|
257
|
-
input.write
|
|
301
|
+
if (agent === undefined) {
|
|
302
|
+
writeRepairHandoffSummary(handoff, input.write);
|
|
303
|
+
input.write("Repair handoff cancelled.\n");
|
|
304
|
+
return undefined;
|
|
258
305
|
}
|
|
306
|
+
input.write(`Selected ${agent.displayName}.\n`);
|
|
307
|
+
input.write(`Launch preview: ${formatRepairAgentPreview(agent.id)}\n`);
|
|
308
|
+
writeRepairHandoffSummary(handoff, input.write);
|
|
259
309
|
const shouldLaunch = await input.prompts.confirm(`Launch ${agent.displayName} now?`, false);
|
|
260
310
|
if (!shouldLaunch) {
|
|
261
311
|
input.write("Agent launch cancelled.\n");
|
|
@@ -301,19 +351,28 @@ const runRepairAgentFlow = async (report, input) => {
|
|
|
301
351
|
throw error;
|
|
302
352
|
}
|
|
303
353
|
};
|
|
354
|
+
const writeRepairHandoffSummary = (handoff, write) => {
|
|
355
|
+
if (handoff.reportDirectory !== undefined) {
|
|
356
|
+
write(`Report directory: ${handoff.reportDirectory}\n`);
|
|
357
|
+
}
|
|
358
|
+
if (handoff.promptPath !== undefined) {
|
|
359
|
+
write(`Repair prompt: ${handoff.promptPath}\n`);
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
write(`Repair prompt:\n${handoff.prompt}\n`);
|
|
363
|
+
}
|
|
364
|
+
if (handoff.reportWriteError !== undefined) {
|
|
365
|
+
write(`Report write failed: ${handoff.reportWriteError.message}\n`);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
304
368
|
const renderFindings = (findings) => `${findings
|
|
305
369
|
.map((finding) => `[${finding.severity}] ${finding.ruleId} ${finding.skillName ?? finding.skillPath}\n${finding.message}\nSuggestion: ${finding.suggestion}`)
|
|
306
370
|
.join("\n\n")}\n`;
|
|
307
371
|
const renderFindingsBySkill = (findings) => {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
}
|
|
313
|
-
return [...groups.entries()]
|
|
314
|
-
.map(([skillName, skillFindings]) => {
|
|
315
|
-
const lines = [`${skillName}:`];
|
|
316
|
-
lines.push(...skillFindings.map((finding) => `- [${finding.severity}] ${finding.ruleId}`));
|
|
372
|
+
return groupFindingsByKey(findings, (finding) => finding.skillName ?? finding.skillPath)
|
|
373
|
+
.map((group) => {
|
|
374
|
+
const lines = [`${group.key}:`];
|
|
375
|
+
lines.push(...group.findings.map((finding) => `- [${finding.severity}] ${finding.ruleId}`));
|
|
317
376
|
return lines.join("\n");
|
|
318
377
|
})
|
|
319
378
|
.join("\n\n")
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
export
|
|
2
|
+
export type BuildProgramOptions = {
|
|
3
|
+
readonly jsonMode?: boolean;
|
|
4
|
+
};
|
|
5
|
+
export declare const buildProgram: (options?: BuildProgramOptions) => Command;
|
|
3
6
|
export declare const main: (argv?: readonly string[]) => Promise<void>;
|
|
7
|
+
export declare const runCli: (argv?: readonly string[]) => Promise<void>;
|
|
4
8
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/cli/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cli/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAMpC,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAC7B,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,UAAS,mBAAwB,KAAG,OAkChE,CAAC;AAEF,eAAO,MAAM,IAAI,GAAU,OAAM,SAAS,MAAM,EAAiB,KAAG,OAAO,CAAC,IAAI,CAiB/E,CAAC;AAEF,eAAO,MAAM,MAAM,GAAU,OAAM,SAAS,MAAM,EAAiB,KAAG,OAAO,CAAC,IAAI,CAEjF,CAAC"}
|
package/dist/cli/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { Command } from "commander";
|
|
2
3
|
import packageJson from "../../package.json" with { type: "json" };
|
|
3
4
|
import { scanAction } from "./commands/scan.js";
|
|
4
5
|
import { handleCliError } from "./utils/handle-error.js";
|
|
5
|
-
|
|
6
|
+
import { enableJsonMode } from "./utils/json-mode.js";
|
|
7
|
+
export const buildProgram = (options = {}) => {
|
|
6
8
|
const program = new Command()
|
|
7
9
|
.name("skills-doctor")
|
|
8
10
|
.description("Scan Agent Skills and report quality issues.")
|
|
@@ -10,13 +12,25 @@ export const buildProgram = () => {
|
|
|
10
12
|
.argument("[directory]", "directory to scan from", ".")
|
|
11
13
|
.option("--json", "output one machine-readable JSON report")
|
|
12
14
|
.option("--json-compact", "with --json, omit indentation")
|
|
15
|
+
.option("--fail-on <severity>", "fail on findings at or above severity: error, warning, advice")
|
|
16
|
+
.option("--min-score <number>", "fail when the scan score is below this threshold")
|
|
13
17
|
.option("-y, --yes", "skip prompts and use conservative defaults")
|
|
14
18
|
.action(async (directory, flags) => {
|
|
15
19
|
await scanAction(directory, flags);
|
|
16
20
|
});
|
|
21
|
+
if (options.jsonMode) {
|
|
22
|
+
program.exitOverride();
|
|
23
|
+
program.configureOutput({
|
|
24
|
+
writeErr: () => { },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
17
27
|
return program;
|
|
18
28
|
};
|
|
19
29
|
export const main = async (argv = process.argv) => {
|
|
30
|
+
const preParseJsonMode = resolvePreParseJsonMode(argv);
|
|
31
|
+
if (preParseJsonMode !== undefined) {
|
|
32
|
+
enableJsonMode(preParseJsonMode);
|
|
33
|
+
}
|
|
20
34
|
process.on("SIGINT", () => process.exit(130));
|
|
21
35
|
process.on("SIGTERM", () => process.exit(143));
|
|
22
36
|
process.stdout.on("error", (error) => {
|
|
@@ -24,10 +38,31 @@ export const main = async (argv = process.argv) => {
|
|
|
24
38
|
process.exit(0);
|
|
25
39
|
});
|
|
26
40
|
try {
|
|
27
|
-
await buildProgram().parseAsync([...argv]);
|
|
41
|
+
await buildProgram({ jsonMode: preParseJsonMode !== undefined }).parseAsync([...argv]);
|
|
28
42
|
}
|
|
29
43
|
finally {
|
|
30
44
|
process.stdin.unref?.();
|
|
31
45
|
}
|
|
32
46
|
};
|
|
33
|
-
|
|
47
|
+
export const runCli = async (argv = process.argv) => {
|
|
48
|
+
await main(argv).catch(handleCliError);
|
|
49
|
+
};
|
|
50
|
+
const resolvePreParseJsonMode = (argv) => {
|
|
51
|
+
const userArgs = argv.slice(2);
|
|
52
|
+
if (!userArgs.includes("--json"))
|
|
53
|
+
return undefined;
|
|
54
|
+
return {
|
|
55
|
+
compact: userArgs.includes("--json-compact"),
|
|
56
|
+
directory: path.resolve(process.cwd(), findDirectoryArg(userArgs) ?? "."),
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
const findDirectoryArg = (args) => args.find((arg, index) => isDirectoryArg(args, arg, index));
|
|
60
|
+
const VALUE_FLAGS = new Set(["--fail-on", "--min-score"]);
|
|
61
|
+
const isDirectoryArg = (args, arg, index) => {
|
|
62
|
+
if (arg === "--")
|
|
63
|
+
return false;
|
|
64
|
+
if (arg.startsWith("-"))
|
|
65
|
+
return false;
|
|
66
|
+
const previous = args[index - 1];
|
|
67
|
+
return previous === undefined || !VALUE_FLAGS.has(previous);
|
|
68
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handoff-to-agent.d.ts","sourceRoot":"","sources":["../../../src/cli/utils/handoff-to-agent.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAC/D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAKrD,OAAO,EAAE,sBAAsB,EAAE,MAAM,0CAA0C,CAAC;AAElF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,MAAM,MAAM,mBAAmB,GAAG,QAAQ,GAAG,qBAAqB,GAAG,KAAK,GAAG,iBAAiB,CAAC;AAE/F,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;CAC/C,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG;IACtC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;IAChC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,sBAAsB,GAAG,SAAS,CAAC;CACrE,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAC/B,OAAO,yBAAyB,KAC/B,OAAO,CAAC,qBAAqB,
|
|
1
|
+
{"version":3,"file":"handoff-to-agent.d.ts","sourceRoot":"","sources":["../../../src/cli/utils/handoff-to-agent.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAC/D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAKrD,OAAO,EAAE,sBAAsB,EAAE,MAAM,0CAA0C,CAAC;AAElF,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAElD,MAAM,MAAM,mBAAmB,GAAG,QAAQ,GAAG,qBAAqB,GAAG,KAAK,GAAG,iBAAiB,CAAC;AAE/F,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,gBAAgB,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;CAC/C,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG;IACtC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;IAChC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,sBAAsB,GAAG,SAAS,CAAC;CACrE,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAC/B,OAAO,yBAAyB,KAC/B,OAAO,CAAC,qBAAqB,CAsC/B,CAAC"}
|
|
@@ -21,25 +21,40 @@ export const prepareRepairHandoff = async (input) => {
|
|
|
21
21
|
reportDirectory: reportResult.result?.directory,
|
|
22
22
|
});
|
|
23
23
|
let promptPath;
|
|
24
|
+
let promptWriteError;
|
|
24
25
|
if (reportResult.result !== undefined) {
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
const targetPromptPath = path.join(reportResult.result.directory, "handoff-prompt.md");
|
|
27
|
+
try {
|
|
28
|
+
await writeFile(targetPromptPath, `${prompt}\n`);
|
|
29
|
+
promptPath = targetPromptPath;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
promptWriteError = normalizeError(error);
|
|
33
|
+
}
|
|
27
34
|
}
|
|
28
35
|
return {
|
|
29
36
|
findings,
|
|
30
37
|
prompt,
|
|
31
38
|
reportDirectory: reportResult.result?.directory,
|
|
32
39
|
promptPath,
|
|
33
|
-
reportWriteError: reportResult.error,
|
|
40
|
+
reportWriteError: reportResult.error ?? promptWriteError,
|
|
34
41
|
};
|
|
35
42
|
};
|
|
36
43
|
const chooseRepairFindings = async (report, prompts) => {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
{ name: "Blocking errors
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
const choices = [];
|
|
45
|
+
if (report.errorCount > 0) {
|
|
46
|
+
choices.push({ name: "Blocking errors only", value: "errors" });
|
|
47
|
+
}
|
|
48
|
+
if (report.errorCount + report.warningCount > 0) {
|
|
49
|
+
choices.push({ name: "Blocking errors and warnings", value: "errors-and-warnings" });
|
|
50
|
+
}
|
|
51
|
+
if (report.findingCount > 0) {
|
|
52
|
+
choices.push({ name: "All findings", value: "all" });
|
|
53
|
+
}
|
|
54
|
+
if (report.skills.some((skill) => skill.findingCount > 0)) {
|
|
55
|
+
choices.push({ name: "Selected skills", value: "selected-skills" });
|
|
56
|
+
}
|
|
57
|
+
const subset = await prompts.select("Choose findings to repair", choices);
|
|
43
58
|
if (subset === "errors") {
|
|
44
59
|
return report.findings.filter((finding) => finding.severity === "error");
|
|
45
60
|
}
|
|
@@ -74,6 +89,7 @@ const tryWriteFindingsDirectory = async (input) => {
|
|
|
74
89
|
};
|
|
75
90
|
}
|
|
76
91
|
catch (error) {
|
|
77
|
-
return { error:
|
|
92
|
+
return { error: normalizeError(error) };
|
|
78
93
|
}
|
|
79
94
|
};
|
|
95
|
+
const normalizeError = (error) => error instanceof Error ? error : new Error(String(error));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-handoff-prompt.d.ts","sourceRoot":"","sources":["../../src/domain/build-handoff-prompt.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"build-handoff-prompt.d.ts","sourceRoot":"","sources":["../../src/domain/build-handoff-prompt.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAEpD,OAAO,KAAK,EAAE,OAAO,EAAa,MAAM,YAAY,CAAC;AAKrD,MAAM,MAAM,uBAAuB,GAAG;IACpC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC/C,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAI,OAAO,uBAAuB,KAAG,MAmEnE,CAAC"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { groupFindingsByKey } from "./group-findings.js";
|
|
1
2
|
const MAX_INLINE_GROUPS = 5;
|
|
2
3
|
const MAX_INLINE_FINDINGS_PER_GROUP = 3;
|
|
3
4
|
export const buildHandoffPrompt = (input) => {
|
|
@@ -38,15 +39,11 @@ export const buildHandoffPrompt = (input) => {
|
|
|
38
39
|
};
|
|
39
40
|
const formatRoots = (roots) => roots.map((root) => `${root.ecosystem}: ${root.rootPath}`).join("; ");
|
|
40
41
|
const groupFindingsBySkill = (findings) => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.map(([skillPath, skillFindings]) => ({
|
|
47
|
-
skillPath,
|
|
48
|
-
skillLabel: skillFindings[0]?.skillName ?? skillPath,
|
|
49
|
-
findings: sortFindings(skillFindings),
|
|
42
|
+
return groupFindingsByKey(findings, (finding) => finding.skillPath)
|
|
43
|
+
.map((group) => ({
|
|
44
|
+
skillPath: group.key,
|
|
45
|
+
skillLabel: group.findings[0]?.skillName ?? group.key,
|
|
46
|
+
findings: sortFindings(group.findings),
|
|
50
47
|
}))
|
|
51
48
|
.sort((left, right) => severityScore(right.findings[0]) - severityScore(left.findings[0]) ||
|
|
52
49
|
right.findings.length - left.findings.length ||
|