skill-check 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +360 -0
- package/bin/skill-check.js +11 -0
- package/dist/cli/main.d.ts +5 -0
- package/dist/cli/main.js +724 -0
- package/dist/core/agent-scan.d.ts +19 -0
- package/dist/core/agent-scan.js +88 -0
- package/dist/core/allowlist.d.ts +1 -0
- package/dist/core/allowlist.js +8 -0
- package/dist/core/analyze.d.ts +6 -0
- package/dist/core/analyze.js +72 -0
- package/dist/core/artifact.d.ts +2 -0
- package/dist/core/artifact.js +33 -0
- package/dist/core/baseline.d.ts +8 -0
- package/dist/core/baseline.js +17 -0
- package/dist/core/config.d.ts +2 -0
- package/dist/core/config.js +215 -0
- package/dist/core/defaults.d.ts +6 -0
- package/dist/core/defaults.js +34 -0
- package/dist/core/discovery.d.ts +2 -0
- package/dist/core/discovery.js +46 -0
- package/dist/core/duplicates.d.ts +2 -0
- package/dist/core/duplicates.js +60 -0
- package/dist/core/errors.d.ts +4 -0
- package/dist/core/errors.js +8 -0
- package/dist/core/fix.d.ts +11 -0
- package/dist/core/fix.js +172 -0
- package/dist/core/formatters.d.ts +4 -0
- package/dist/core/formatters.js +182 -0
- package/dist/core/frontmatter.d.ts +7 -0
- package/dist/core/frontmatter.js +39 -0
- package/dist/core/github-formatter.d.ts +2 -0
- package/dist/core/github-formatter.js +10 -0
- package/dist/core/html-report.d.ts +3 -0
- package/dist/core/html-report.js +320 -0
- package/dist/core/interactive-fix.d.ts +9 -0
- package/dist/core/interactive-fix.js +22 -0
- package/dist/core/links.d.ts +17 -0
- package/dist/core/links.js +94 -0
- package/dist/core/open-browser.d.ts +6 -0
- package/dist/core/open-browser.js +20 -0
- package/dist/core/plugins.d.ts +2 -0
- package/dist/core/plugins.js +41 -0
- package/dist/core/quality-score.d.ts +14 -0
- package/dist/core/quality-score.js +55 -0
- package/dist/core/report.d.ts +2 -0
- package/dist/core/report.js +26 -0
- package/dist/core/rule-engine.d.ts +2 -0
- package/dist/core/rule-engine.js +52 -0
- package/dist/core/sarif.d.ts +2 -0
- package/dist/core/sarif.js +48 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/rules/core/body.d.ts +2 -0
- package/dist/rules/core/body.js +39 -0
- package/dist/rules/core/description.d.ts +2 -0
- package/dist/rules/core/description.js +66 -0
- package/dist/rules/core/file.d.ts +2 -0
- package/dist/rules/core/file.js +26 -0
- package/dist/rules/core/frontmatter.d.ts +2 -0
- package/dist/rules/core/frontmatter.js +124 -0
- package/dist/rules/core/index.d.ts +2 -0
- package/dist/rules/core/index.js +12 -0
- package/dist/rules/core/links.d.ts +2 -0
- package/dist/rules/core/links.js +54 -0
- package/dist/types.d.ts +92 -0
- package/dist/types.js +1 -0
- package/package.json +82 -0
- package/schemas/config.schema.json +53 -0
- package/skills/skill-check/SKILL.md +76 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
function escapeHtml(s) {
|
|
2
|
+
return s
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
.replace(/'/g, ''');
|
|
8
|
+
}
|
|
9
|
+
function groupByFile(diagnostics) {
|
|
10
|
+
const grouped = new Map();
|
|
11
|
+
for (const d of diagnostics) {
|
|
12
|
+
const list = grouped.get(d.file) ?? [];
|
|
13
|
+
list.push(d);
|
|
14
|
+
grouped.set(d.file, list);
|
|
15
|
+
}
|
|
16
|
+
return grouped;
|
|
17
|
+
}
|
|
18
|
+
function severityClass(severity) {
|
|
19
|
+
return severity === 'error' ? 'severity-error' : 'severity-warn';
|
|
20
|
+
}
|
|
21
|
+
function statusClass(summary) {
|
|
22
|
+
if (summary.errorCount > 0)
|
|
23
|
+
return 'status-fail';
|
|
24
|
+
if (summary.warningCount > 0)
|
|
25
|
+
return 'status-warn';
|
|
26
|
+
return 'status-pass';
|
|
27
|
+
}
|
|
28
|
+
function scoreColor(score) {
|
|
29
|
+
if (score >= 80)
|
|
30
|
+
return '#16a34a';
|
|
31
|
+
if (score >= 50)
|
|
32
|
+
return '#d97706';
|
|
33
|
+
return '#dc2626';
|
|
34
|
+
}
|
|
35
|
+
export function renderHtml(result, scores) {
|
|
36
|
+
const grouped = groupByFile(result.diagnostics);
|
|
37
|
+
const sortedFiles = Array.from(grouped.keys()).sort();
|
|
38
|
+
const status = statusClass(result.summary);
|
|
39
|
+
const generated = new Date().toISOString();
|
|
40
|
+
const summarySection = `
|
|
41
|
+
<section class="summary" aria-label="Summary">
|
|
42
|
+
<div class="summary-grid">
|
|
43
|
+
<div class="summary-card">
|
|
44
|
+
<span class="summary-value">${result.summary.skillCount}</span>
|
|
45
|
+
<span class="summary-label">Skills</span>
|
|
46
|
+
</div>
|
|
47
|
+
<div class="summary-card">
|
|
48
|
+
<span class="summary-value severity-error">${result.summary.errorCount}</span>
|
|
49
|
+
<span class="summary-label">Errors</span>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="summary-card">
|
|
52
|
+
<span class="summary-value severity-warn">${result.summary.warningCount}</span>
|
|
53
|
+
<span class="summary-label">Warnings</span>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="summary-card status-badge ${status}">
|
|
56
|
+
<span class="summary-value">${result.summary.errorCount > 0 ? 'FAIL' : result.summary.warningCount > 0 ? 'WARN' : 'PASS'}</span>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</section>`;
|
|
60
|
+
let diagnosticsSection;
|
|
61
|
+
if (grouped.size === 0) {
|
|
62
|
+
diagnosticsSection = `
|
|
63
|
+
<section class="diagnostics" aria-label="Diagnostics">
|
|
64
|
+
<p class="no-diagnostics">No diagnostics found.</p>
|
|
65
|
+
</section>`;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
const fileSections = sortedFiles
|
|
69
|
+
.map((file) => {
|
|
70
|
+
const diagnostics = grouped.get(file) ?? [];
|
|
71
|
+
const rows = diagnostics
|
|
72
|
+
.map((d) => `
|
|
73
|
+
<tr class="diagnostic-row" data-search="${escapeHtml([d.ruleId, d.message, d.file, d.suggestion ?? ''].join(' ').toLowerCase())}">
|
|
74
|
+
<td class="severity ${severityClass(d.severity)}">${d.severity}</td>
|
|
75
|
+
<td class="rule-id"><code>${escapeHtml(d.ruleId)}</code></td>
|
|
76
|
+
<td class="message">${escapeHtml(d.message)}</td>
|
|
77
|
+
<td class="location">${d.line}:${d.column}</td>
|
|
78
|
+
${d.suggestion ? `<td class="suggestion">${escapeHtml(d.suggestion)}</td>` : '<td></td>'}
|
|
79
|
+
</tr>`)
|
|
80
|
+
.join('');
|
|
81
|
+
return `
|
|
82
|
+
<details class="file-block" open>
|
|
83
|
+
<summary class="file-summary">
|
|
84
|
+
<span class="file-path">${escapeHtml(file)}</span>
|
|
85
|
+
<span class="file-count">${diagnostics.length} diagnostic(s)</span>
|
|
86
|
+
</summary>
|
|
87
|
+
<div class="table-wrap">
|
|
88
|
+
<table class="diagnostics-table">
|
|
89
|
+
<thead>
|
|
90
|
+
<tr>
|
|
91
|
+
<th>Severity</th>
|
|
92
|
+
<th>Rule</th>
|
|
93
|
+
<th>Message</th>
|
|
94
|
+
<th>Location</th>
|
|
95
|
+
<th>Suggestion</th>
|
|
96
|
+
</tr>
|
|
97
|
+
</thead>
|
|
98
|
+
<tbody>${rows}</tbody>
|
|
99
|
+
</table>
|
|
100
|
+
</div>
|
|
101
|
+
</details>`;
|
|
102
|
+
})
|
|
103
|
+
.join('');
|
|
104
|
+
diagnosticsSection = `
|
|
105
|
+
<section class="diagnostics" aria-label="Diagnostics">
|
|
106
|
+
<div class="filter-wrap">
|
|
107
|
+
<label for="report-filter">Filter</label>
|
|
108
|
+
<input type="search" id="report-filter" placeholder="Search by rule, message, file…" autocomplete="off" />
|
|
109
|
+
</div>
|
|
110
|
+
<div class="file-blocks">${fileSections}</div>
|
|
111
|
+
</section>`;
|
|
112
|
+
}
|
|
113
|
+
return `<!DOCTYPE html>
|
|
114
|
+
<html lang="en">
|
|
115
|
+
<head>
|
|
116
|
+
<meta charset="utf-8" />
|
|
117
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
118
|
+
<title>skill-check Report</title>
|
|
119
|
+
<style>
|
|
120
|
+
:root {
|
|
121
|
+
--bg: #f8fafc;
|
|
122
|
+
--bg-card: #fff;
|
|
123
|
+
--text: #1e293b;
|
|
124
|
+
--text-muted: #64748b;
|
|
125
|
+
--border: #e2e8f0;
|
|
126
|
+
--error: #dc2626;
|
|
127
|
+
--warn: #d97706;
|
|
128
|
+
--pass: #16a34a;
|
|
129
|
+
--code-bg: #f1f5f9;
|
|
130
|
+
}
|
|
131
|
+
@media (prefers-color-scheme: dark) {
|
|
132
|
+
:root {
|
|
133
|
+
--bg: #0f172a;
|
|
134
|
+
--bg-card: #1e293b;
|
|
135
|
+
--text: #f1f5f9;
|
|
136
|
+
--text-muted: #94a3b8;
|
|
137
|
+
--border: #334155;
|
|
138
|
+
--code-bg: #334155;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
* { box-sizing: border-box; }
|
|
142
|
+
body {
|
|
143
|
+
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
144
|
+
line-height: 1.5;
|
|
145
|
+
color: var(--text);
|
|
146
|
+
background: var(--bg);
|
|
147
|
+
margin: 0;
|
|
148
|
+
padding: 1rem 1.5rem 2rem;
|
|
149
|
+
max-width: 1200px;
|
|
150
|
+
margin-inline: auto;
|
|
151
|
+
}
|
|
152
|
+
header {
|
|
153
|
+
margin-bottom: 1.5rem;
|
|
154
|
+
}
|
|
155
|
+
header h1 {
|
|
156
|
+
font-size: 1.5rem;
|
|
157
|
+
font-weight: 700;
|
|
158
|
+
margin: 0;
|
|
159
|
+
}
|
|
160
|
+
.summary {
|
|
161
|
+
margin-bottom: 2rem;
|
|
162
|
+
}
|
|
163
|
+
.summary-grid {
|
|
164
|
+
display: grid;
|
|
165
|
+
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
166
|
+
gap: 1rem;
|
|
167
|
+
}
|
|
168
|
+
.summary-card {
|
|
169
|
+
background: var(--bg-card);
|
|
170
|
+
border: 1px solid var(--border);
|
|
171
|
+
border-radius: 8px;
|
|
172
|
+
padding: 1rem;
|
|
173
|
+
}
|
|
174
|
+
.summary-value {
|
|
175
|
+
display: block;
|
|
176
|
+
font-size: 1.5rem;
|
|
177
|
+
font-weight: 700;
|
|
178
|
+
}
|
|
179
|
+
.summary-value.severity-error { color: var(--error); }
|
|
180
|
+
.summary-value.severity-warn { color: var(--warn); }
|
|
181
|
+
.summary-label {
|
|
182
|
+
font-size: 0.875rem;
|
|
183
|
+
color: var(--text-muted);
|
|
184
|
+
}
|
|
185
|
+
.status-badge .summary-value {
|
|
186
|
+
font-size: 1.25rem;
|
|
187
|
+
}
|
|
188
|
+
.status-badge.status-fail .summary-value { color: var(--error); }
|
|
189
|
+
.status-badge.status-warn .summary-value { color: var(--warn); }
|
|
190
|
+
.status-badge.status-pass .summary-value { color: var(--pass); }
|
|
191
|
+
.diagnostics h2 {
|
|
192
|
+
font-size: 1.125rem;
|
|
193
|
+
margin-bottom: 0.75rem;
|
|
194
|
+
}
|
|
195
|
+
.filter-wrap {
|
|
196
|
+
margin-bottom: 1rem;
|
|
197
|
+
}
|
|
198
|
+
.filter-wrap label {
|
|
199
|
+
margin-right: 0.5rem;
|
|
200
|
+
color: var(--text-muted);
|
|
201
|
+
}
|
|
202
|
+
.filter-wrap input {
|
|
203
|
+
padding: 0.5rem 0.75rem;
|
|
204
|
+
border: 1px solid var(--border);
|
|
205
|
+
border-radius: 6px;
|
|
206
|
+
background: var(--bg-card);
|
|
207
|
+
color: var(--text);
|
|
208
|
+
width: min(100%, 320px);
|
|
209
|
+
}
|
|
210
|
+
.file-blocks { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
211
|
+
.file-block {
|
|
212
|
+
background: var(--bg-card);
|
|
213
|
+
border: 1px solid var(--border);
|
|
214
|
+
border-radius: 8px;
|
|
215
|
+
overflow: hidden;
|
|
216
|
+
}
|
|
217
|
+
.file-summary {
|
|
218
|
+
padding: 0.75rem 1rem;
|
|
219
|
+
cursor: pointer;
|
|
220
|
+
font-weight: 500;
|
|
221
|
+
display: flex;
|
|
222
|
+
justify-content: space-between;
|
|
223
|
+
align-items: center;
|
|
224
|
+
gap: 1rem;
|
|
225
|
+
}
|
|
226
|
+
.file-path { word-break: break-all; }
|
|
227
|
+
.file-count { font-size: 0.875rem; color: var(--text-muted); flex-shrink: 0; }
|
|
228
|
+
.table-wrap { overflow-x: auto; }
|
|
229
|
+
.diagnostics-table {
|
|
230
|
+
width: 100%;
|
|
231
|
+
border-collapse: collapse;
|
|
232
|
+
font-size: 0.875rem;
|
|
233
|
+
}
|
|
234
|
+
.diagnostics-table th,
|
|
235
|
+
.diagnostics-table td {
|
|
236
|
+
padding: 0.5rem 0.75rem;
|
|
237
|
+
text-align: left;
|
|
238
|
+
border-top: 1px solid var(--border);
|
|
239
|
+
}
|
|
240
|
+
.diagnostics-table th {
|
|
241
|
+
color: var(--text-muted);
|
|
242
|
+
font-weight: 500;
|
|
243
|
+
}
|
|
244
|
+
.diagnostic-row.hidden { display: none; }
|
|
245
|
+
.severity-error { color: var(--error); font-weight: 600; }
|
|
246
|
+
.severity-warn { color: var(--warn); font-weight: 600; }
|
|
247
|
+
.rule-id code {
|
|
248
|
+
background: var(--code-bg);
|
|
249
|
+
padding: 0.2em 0.4em;
|
|
250
|
+
border-radius: 4px;
|
|
251
|
+
font-size: 0.8125rem;
|
|
252
|
+
}
|
|
253
|
+
.no-diagnostics { color: var(--text-muted); margin: 0; }
|
|
254
|
+
.scores { margin-top: 2rem; }
|
|
255
|
+
.scores h2 { font-size: 1.125rem; margin-bottom: 0.75rem; }
|
|
256
|
+
.scores-grid { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
257
|
+
.score-card { display: flex; align-items: center; gap: 0.75rem; }
|
|
258
|
+
.score-bar {
|
|
259
|
+
width: 120px; height: 20px; background: var(--border); border-radius: 4px;
|
|
260
|
+
position: relative; overflow: hidden; flex-shrink: 0;
|
|
261
|
+
}
|
|
262
|
+
.score-bar::before {
|
|
263
|
+
content: ''; position: absolute; inset: 0;
|
|
264
|
+
width: var(--score-pct); background: var(--score-color);
|
|
265
|
+
border-radius: 4px; transition: width 0.3s;
|
|
266
|
+
}
|
|
267
|
+
.score-value {
|
|
268
|
+
position: relative; z-index: 1; font-size: 0.75rem; font-weight: 700;
|
|
269
|
+
color: #fff; padding-left: 6px; line-height: 20px;
|
|
270
|
+
}
|
|
271
|
+
.score-path { font-size: 0.875rem; color: var(--text-muted); word-break: break-all; }
|
|
272
|
+
footer {
|
|
273
|
+
margin-top: 2rem;
|
|
274
|
+
font-size: 0.8125rem;
|
|
275
|
+
color: var(--text-muted);
|
|
276
|
+
}
|
|
277
|
+
</style>
|
|
278
|
+
</head>
|
|
279
|
+
<body>
|
|
280
|
+
<header>
|
|
281
|
+
<h1>skill-check Report</h1>
|
|
282
|
+
</header>
|
|
283
|
+
${summarySection}
|
|
284
|
+
${diagnosticsSection}
|
|
285
|
+
${scores && scores.length > 0
|
|
286
|
+
? `
|
|
287
|
+
<section class="scores" aria-label="Quality Scores">
|
|
288
|
+
<h2>Quality Scores</h2>
|
|
289
|
+
<div class="scores-grid">${scores
|
|
290
|
+
.map((s) => `
|
|
291
|
+
<div class="score-card">
|
|
292
|
+
<div class="score-bar" style="--score-pct: ${s.score}%; --score-color: ${scoreColor(s.score)}">
|
|
293
|
+
<span class="score-value">${s.score}</span>
|
|
294
|
+
</div>
|
|
295
|
+
<span class="score-path">${escapeHtml(s.relativePath)}</span>
|
|
296
|
+
</div>`)
|
|
297
|
+
.join('')}
|
|
298
|
+
</div>
|
|
299
|
+
</section>`
|
|
300
|
+
: ''}
|
|
301
|
+
<footer>
|
|
302
|
+
Generated: ${escapeHtml(generated)}
|
|
303
|
+
</footer>
|
|
304
|
+
<script>
|
|
305
|
+
(function() {
|
|
306
|
+
var filter = document.getElementById('report-filter');
|
|
307
|
+
if (!filter) return;
|
|
308
|
+
var rows = document.querySelectorAll('.diagnostic-row');
|
|
309
|
+
filter.addEventListener('input', function() {
|
|
310
|
+
var q = this.value.trim().toLowerCase();
|
|
311
|
+
rows.forEach(function(row) {
|
|
312
|
+
var text = (row.getAttribute('data-search') || '');
|
|
313
|
+
row.classList.toggle('hidden', q && text.indexOf(q) === -1);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
})();
|
|
317
|
+
</script>
|
|
318
|
+
</body>
|
|
319
|
+
</html>`;
|
|
320
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AnalysisResult, Diagnostic } from '../types.js';
|
|
2
|
+
import { type AutoFixSummary } from './fix.js';
|
|
3
|
+
export interface InteractiveFixResult extends AutoFixSummary {
|
|
4
|
+
skippedByUser: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function selectFixableDiagnostics(result: AnalysisResult): Promise<{
|
|
7
|
+
accepted: Diagnostic[];
|
|
8
|
+
skipped: number;
|
|
9
|
+
}>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { confirm as clackConfirm, isCancel } from '@clack/prompts';
|
|
2
|
+
import { isFixableRuleId } from './fix.js';
|
|
3
|
+
export async function selectFixableDiagnostics(result) {
|
|
4
|
+
const fixable = result.diagnostics.filter((d) => isFixableRuleId(d.ruleId));
|
|
5
|
+
const accepted = [];
|
|
6
|
+
let skipped = 0;
|
|
7
|
+
for (const d of fixable) {
|
|
8
|
+
const answer = await clackConfirm({
|
|
9
|
+
message: `Fix ${d.ruleId} in ${d.file}:${d.line} — ${d.message}?`,
|
|
10
|
+
initialValue: true,
|
|
11
|
+
});
|
|
12
|
+
if (isCancel(answer))
|
|
13
|
+
break;
|
|
14
|
+
if (answer) {
|
|
15
|
+
accepted.push(d);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
skipped += 1;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { accepted, skipped };
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface MarkdownLink {
|
|
2
|
+
rawTarget: string;
|
|
3
|
+
normalizedTarget: string;
|
|
4
|
+
index: number;
|
|
5
|
+
line: number;
|
|
6
|
+
column: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function getLineColumn(content: string, index: number): {
|
|
9
|
+
line: number;
|
|
10
|
+
column: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function normalizeTarget(rawTarget: string): string;
|
|
13
|
+
export declare function isExternalTarget(target: string): boolean;
|
|
14
|
+
export declare function isLocalResolvableTarget(target: string): boolean;
|
|
15
|
+
export declare function stripQueryAndFragment(target: string): string;
|
|
16
|
+
export declare function extractMarkdownLinks(content: string): MarkdownLink[];
|
|
17
|
+
export declare function resolveLocalTarget(filePath: string, target: string): string;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
function findFenceRanges(content) {
|
|
3
|
+
const ranges = [];
|
|
4
|
+
const lines = content.split(/\r?\n/);
|
|
5
|
+
let offset = 0;
|
|
6
|
+
let activeFence = null;
|
|
7
|
+
for (const line of lines) {
|
|
8
|
+
const trimmed = line.trimStart();
|
|
9
|
+
const fenceMatch = trimmed.match(/^(```+|~~~+)/);
|
|
10
|
+
if (fenceMatch) {
|
|
11
|
+
const marker = fenceMatch[1][0];
|
|
12
|
+
if (!activeFence) {
|
|
13
|
+
activeFence = { marker, start: offset };
|
|
14
|
+
}
|
|
15
|
+
else if (activeFence.marker === marker) {
|
|
16
|
+
ranges.push({ start: activeFence.start, end: offset + line.length });
|
|
17
|
+
activeFence = null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
offset += line.length + 1;
|
|
21
|
+
}
|
|
22
|
+
if (activeFence) {
|
|
23
|
+
ranges.push({ start: activeFence.start, end: content.length });
|
|
24
|
+
}
|
|
25
|
+
return ranges;
|
|
26
|
+
}
|
|
27
|
+
function inRanges(index, ranges) {
|
|
28
|
+
return ranges.some((range) => index >= range.start && index <= range.end);
|
|
29
|
+
}
|
|
30
|
+
export function getLineColumn(content, index) {
|
|
31
|
+
const prior = content.slice(0, index);
|
|
32
|
+
const lines = prior.split(/\r?\n/);
|
|
33
|
+
const line = lines.length;
|
|
34
|
+
const column = (lines[lines.length - 1] ?? '').length + 1;
|
|
35
|
+
return { line, column };
|
|
36
|
+
}
|
|
37
|
+
export function normalizeTarget(rawTarget) {
|
|
38
|
+
let target = rawTarget.trim();
|
|
39
|
+
if (!target)
|
|
40
|
+
return '';
|
|
41
|
+
if (target.startsWith('<') && target.endsWith('>')) {
|
|
42
|
+
target = target.slice(1, -1).trim();
|
|
43
|
+
}
|
|
44
|
+
const titled = target.match(/^(\S+)\s+["'(].*$/);
|
|
45
|
+
if (titled) {
|
|
46
|
+
target = titled[1];
|
|
47
|
+
}
|
|
48
|
+
return target.replace(/^['"]|['"]$/g, '').trim();
|
|
49
|
+
}
|
|
50
|
+
export function isExternalTarget(target) {
|
|
51
|
+
return /^(https?:|mailto:|tel:|data:|javascript:)/i.test(target);
|
|
52
|
+
}
|
|
53
|
+
export function isLocalResolvableTarget(target) {
|
|
54
|
+
if (!target)
|
|
55
|
+
return false;
|
|
56
|
+
if (target.startsWith('#'))
|
|
57
|
+
return false;
|
|
58
|
+
if (target.startsWith('/'))
|
|
59
|
+
return false;
|
|
60
|
+
if (target.includes('{') || target.includes('}'))
|
|
61
|
+
return false;
|
|
62
|
+
if (isExternalTarget(target))
|
|
63
|
+
return false;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
export function stripQueryAndFragment(target) {
|
|
67
|
+
return target.split('#')[0].split('?')[0].trim();
|
|
68
|
+
}
|
|
69
|
+
export function extractMarkdownLinks(content) {
|
|
70
|
+
const links = [];
|
|
71
|
+
const ranges = findFenceRanges(content);
|
|
72
|
+
const re = /\[[^\]]*\]\(([^)]+)\)/g;
|
|
73
|
+
let match = re.exec(content);
|
|
74
|
+
while (match !== null) {
|
|
75
|
+
if (!inRanges(match.index, ranges)) {
|
|
76
|
+
const rawTarget = match[1];
|
|
77
|
+
const normalizedTarget = normalizeTarget(rawTarget);
|
|
78
|
+
const { line, column } = getLineColumn(content, match.index);
|
|
79
|
+
links.push({
|
|
80
|
+
rawTarget,
|
|
81
|
+
normalizedTarget,
|
|
82
|
+
index: match.index,
|
|
83
|
+
line,
|
|
84
|
+
column,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
match = re.exec(content);
|
|
88
|
+
}
|
|
89
|
+
return links;
|
|
90
|
+
}
|
|
91
|
+
export function resolveLocalTarget(filePath, target) {
|
|
92
|
+
const clean = stripQueryAndFragment(target);
|
|
93
|
+
return path.resolve(path.dirname(filePath), clean);
|
|
94
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a URL or file path in the user's default browser.
|
|
3
|
+
* Cross-platform: macOS (open), Linux (xdg-open), Windows (start).
|
|
4
|
+
* Uses the absolute file path (not file://) so the OS opens it with the default app and avoids browser file-URL restrictions.
|
|
5
|
+
*/
|
|
6
|
+
export declare function openInBrowser(urlOrPath: string): void;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { platform } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
/**
|
|
6
|
+
* Open a URL or file path in the user's default browser.
|
|
7
|
+
* Cross-platform: macOS (open), Linux (xdg-open), Windows (start).
|
|
8
|
+
* Uses the absolute file path (not file://) so the OS opens it with the default app and avoids browser file-URL restrictions.
|
|
9
|
+
*/
|
|
10
|
+
export function openInBrowser(urlOrPath) {
|
|
11
|
+
const absolutePath = urlOrPath.startsWith('file:')
|
|
12
|
+
? fileURLToPath(urlOrPath)
|
|
13
|
+
: path.resolve(urlOrPath);
|
|
14
|
+
const command = platform() === 'win32'
|
|
15
|
+
? `start "" "${absolutePath}"`
|
|
16
|
+
: platform() === 'darwin'
|
|
17
|
+
? `open "${absolutePath}"`
|
|
18
|
+
: `xdg-open "${absolutePath}"`;
|
|
19
|
+
execSync(command, { stdio: 'ignore' });
|
|
20
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { CliError } from './errors.js';
|
|
4
|
+
export async function loadPluginRules(config) {
|
|
5
|
+
const rules = [];
|
|
6
|
+
for (const pluginRef of config.plugins) {
|
|
7
|
+
const isPathRef = pluginRef.startsWith('.') ||
|
|
8
|
+
pluginRef.startsWith('/') ||
|
|
9
|
+
pluginRef.includes(path.sep);
|
|
10
|
+
const resolvedRef = isPathRef
|
|
11
|
+
? path.resolve(config.cwd, pluginRef)
|
|
12
|
+
: pluginRef;
|
|
13
|
+
let plugin;
|
|
14
|
+
try {
|
|
15
|
+
const module = isPathRef
|
|
16
|
+
? await import(pathToFileURL(resolvedRef).href)
|
|
17
|
+
: await import(resolvedRef);
|
|
18
|
+
plugin = module;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
22
|
+
throw new CliError(`Failed to load plugin "${pluginRef}": ${message}`, 2);
|
|
23
|
+
}
|
|
24
|
+
if (!Array.isArray(plugin.rules)) {
|
|
25
|
+
throw new CliError(`Plugin "${pluginRef}" must export a rules array.`, 2);
|
|
26
|
+
}
|
|
27
|
+
for (const rule of plugin.rules) {
|
|
28
|
+
if (!rule || typeof rule !== 'object') {
|
|
29
|
+
throw new CliError(`Plugin "${pluginRef}" contains an invalid rule entry.`, 2);
|
|
30
|
+
}
|
|
31
|
+
if (typeof rule.id !== 'string' || !rule.id) {
|
|
32
|
+
throw new CliError(`Plugin "${pluginRef}" has a rule without a valid id.`, 2);
|
|
33
|
+
}
|
|
34
|
+
if (typeof rule.evaluate !== 'function') {
|
|
35
|
+
throw new CliError(`Plugin "${pluginRef}" rule "${rule.id}" is missing evaluate().`, 2);
|
|
36
|
+
}
|
|
37
|
+
rules.push(rule);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return rules;
|
|
41
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Diagnostic, SkillArtifact } from '../types.js';
|
|
2
|
+
export interface SkillScore {
|
|
3
|
+
skillId: string;
|
|
4
|
+
relativePath: string;
|
|
5
|
+
score: number;
|
|
6
|
+
breakdown: {
|
|
7
|
+
frontmatter: number;
|
|
8
|
+
description: number;
|
|
9
|
+
body: number;
|
|
10
|
+
links: number;
|
|
11
|
+
file: number;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export declare function computeSkillScores(skills: SkillArtifact[], diagnostics: Diagnostic[]): SkillScore[];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const CATEGORY_WEIGHTS = {
|
|
2
|
+
frontmatter: 30,
|
|
3
|
+
description: 30,
|
|
4
|
+
body: 20,
|
|
5
|
+
links: 10,
|
|
6
|
+
file: 10,
|
|
7
|
+
};
|
|
8
|
+
function categorize(ruleId) {
|
|
9
|
+
if (ruleId.startsWith('frontmatter.'))
|
|
10
|
+
return 'frontmatter';
|
|
11
|
+
if (ruleId.startsWith('description.'))
|
|
12
|
+
return 'description';
|
|
13
|
+
if (ruleId.startsWith('body.'))
|
|
14
|
+
return 'body';
|
|
15
|
+
if (ruleId.startsWith('links.'))
|
|
16
|
+
return 'links';
|
|
17
|
+
return 'file';
|
|
18
|
+
}
|
|
19
|
+
export function computeSkillScores(skills, diagnostics) {
|
|
20
|
+
const diagnosticsByFile = new Map();
|
|
21
|
+
for (const d of diagnostics) {
|
|
22
|
+
const list = diagnosticsByFile.get(d.file) ?? [];
|
|
23
|
+
list.push(d);
|
|
24
|
+
diagnosticsByFile.set(d.file, list);
|
|
25
|
+
}
|
|
26
|
+
return skills.map((skill) => {
|
|
27
|
+
const fileDiagnostics = diagnosticsByFile.get(skill.relativePath) ?? [];
|
|
28
|
+
const penalties = {
|
|
29
|
+
frontmatter: 0,
|
|
30
|
+
description: 0,
|
|
31
|
+
body: 0,
|
|
32
|
+
links: 0,
|
|
33
|
+
file: 0,
|
|
34
|
+
};
|
|
35
|
+
for (const d of fileDiagnostics) {
|
|
36
|
+
const cat = categorize(d.ruleId);
|
|
37
|
+
const penalty = d.severity === 'error' ? 1 : 0.5;
|
|
38
|
+
penalties[cat] += penalty;
|
|
39
|
+
}
|
|
40
|
+
const breakdown = {};
|
|
41
|
+
let total = 0;
|
|
42
|
+
for (const [cat, weight] of Object.entries(CATEGORY_WEIGHTS)) {
|
|
43
|
+
const key = cat;
|
|
44
|
+
const raw = Math.max(0, weight - penalties[key] * weight);
|
|
45
|
+
breakdown[key] = Math.round(raw);
|
|
46
|
+
total += breakdown[key];
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
skillId: skill.id,
|
|
50
|
+
relativePath: skill.relativePath,
|
|
51
|
+
score: Math.min(100, Math.max(0, total)),
|
|
52
|
+
breakdown,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function renderMarkdownReport(result) {
|
|
2
|
+
const lines = [];
|
|
3
|
+
lines.push('# Skill Check Report');
|
|
4
|
+
lines.push('');
|
|
5
|
+
lines.push(`Generated: ${new Date().toISOString()}`);
|
|
6
|
+
lines.push('');
|
|
7
|
+
lines.push('## Summary');
|
|
8
|
+
lines.push('');
|
|
9
|
+
lines.push(`- Skills: ${result.summary.skillCount}`);
|
|
10
|
+
lines.push(`- Errors: ${result.summary.errorCount}`);
|
|
11
|
+
lines.push(`- Warnings: ${result.summary.warningCount}`);
|
|
12
|
+
lines.push('');
|
|
13
|
+
lines.push('## Diagnostics');
|
|
14
|
+
lines.push('');
|
|
15
|
+
lines.push('| File | Severity | Rule | Message |');
|
|
16
|
+
lines.push('| --- | --- | --- | --- |');
|
|
17
|
+
if (result.diagnostics.length === 0) {
|
|
18
|
+
lines.push('| - | - | - | No diagnostics found |');
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
for (const d of result.diagnostics) {
|
|
22
|
+
lines.push(`| ${d.file}:${d.line}:${d.column} | ${d.severity} | ${d.ruleId} | ${d.message} |`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
26
|
+
}
|