Skip to content

Commit 044dd1a

Browse files
committed
refactor: Restructure skill-lint to 6 generic validators with multi-skill support
BREAKING CHANGE: Validator names changed from 4 to 6: - performance → size (line count, token budgets) - NEW: references (reference file analysis, README conciseness) - NEW: links (relative/anchor/external link validation) - triggering → keywords (keyword-based simulation + quality scoring) - integration → harness (real Claude CLI execution with quality metrics) - structure (unchanged) Features: - Multi-skill linting: point at directory of skills to lint all - Backward compatible: old config keys (performance/triggering/integration) still work - Enhanced validation: 45 new rules across all validators - Improved CLI: --size, --references, --links, --keywords, --harness flags Config changes: - scenarios.performance → scenarios.size - scenarios.triggering → scenarios.keywords - scenarios.integration → scenarios.harness - scenarios.links: now object { enabled, checkExternal } - Old keys automatically mapped via Zod preprocess Tests: - 463 tests passing (+45 from 418) - 5 new test files - All existing tests updated for new config shape
1 parent 7d3a309 commit 044dd1a

28 files changed

Lines changed: 2448 additions & 301 deletions

plugins/ui5/skill-lint/README.md

Lines changed: 169 additions & 186 deletions
Large diffs are not rendered by default.

plugins/ui5/skill-lint/src/cli/commands/lint.ts

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import { resolve, relative, isAbsolute, join, dirname } from 'path';
6-
import { realpath, access, constants } from 'fs/promises';
6+
import { realpath, access, readdir, stat, constants } from 'fs/promises';
77
import { existsSync } from 'fs';
88
import { SkillLinter } from '../../core/linter.js';
99
import { loadConfig, mergeWithDefaults } from '../../config/loader.js';
@@ -20,6 +20,12 @@ export interface LintOptions {
2020
format?: string;
2121
output?: string;
2222
structure?: boolean;
23+
size?: boolean;
24+
references?: boolean;
25+
links?: boolean;
26+
keywords?: boolean;
27+
harness?: boolean;
28+
// Backward compat CLI flags
2329
triggering?: boolean;
2430
performance?: boolean;
2531
integration?: boolean;
@@ -43,11 +49,37 @@ export async function lintCommand(
4349
}
4450

4551
// Validate skill path for security (prevents path traversal attacks)
46-
const resolvedPath = await validateSkillPath(skillPath);
52+
const resolvedPaths = await resolveSkillPaths(skillPath);
4753
const config = await buildConfig(options);
4854
const formatter = getFormatter(config.formatters.default, config.formatters.options.colors);
4955
const isMachineFormat = config.formatters.default !== 'text';
5056

57+
if (resolvedPaths.length > 1) {
58+
// Multi-skill mode
59+
if (!isMachineFormat) {
60+
Logger.start(`Linting ${resolvedPaths.length} skills in ${skillPath}`);
61+
}
62+
63+
const linter = new SkillLinter(config);
64+
let allPassed = true;
65+
66+
for (const resolvedPath of resolvedPaths) {
67+
const result = await linter.lint(resolvedPath, config);
68+
const output = formatter.format(result);
69+
console.log(output);
70+
if (!result.passed) allPassed = false;
71+
}
72+
73+
if (options.output) {
74+
Logger.document(`Multi-skill report: use --format json for combined output`);
75+
}
76+
77+
return allPassed ? 0 : 1;
78+
}
79+
80+
// Single-skill mode
81+
const resolvedPath = resolvedPaths[0];
82+
5183
if (!isMachineFormat) {
5284
Logger.start(`Linting ${resolvedPath}`);
5385
}
@@ -73,6 +105,58 @@ export async function lintCommand(
73105
}
74106
}
75107

108+
/**
109+
* Resolve one or more SKILL.md paths from the given input path.
110+
* If the path is a directory containing multiple skill subdirectories, returns all of them.
111+
* Otherwise delegates to validateSkillPath for a single path.
112+
*/
113+
async function resolveSkillPaths(skillPath: string): Promise<string[]> {
114+
// SECURITY: Sanitize path first
115+
let sanitized: string;
116+
try {
117+
sanitized = sanitizePath(skillPath);
118+
} catch (error) {
119+
throw new Error(`Invalid skill path: ${error instanceof Error ? error.message : String(error)}`);
120+
}
121+
122+
const workspaceRoot = await findGitRoot() || process.cwd();
123+
const resolved = resolve(workspaceRoot, sanitized);
124+
125+
if (!existsSync(resolved)) {
126+
throw new Error(`Skill path does not exist: ${skillPath}`);
127+
}
128+
129+
// If it's a file, treat as single skill
130+
const stats = await stat(resolved);
131+
if (stats.isFile()) {
132+
return [await validateSkillPath(skillPath)];
133+
}
134+
135+
// If it's a directory, check for SKILL.md directly inside
136+
const directSkill = join(resolved, 'SKILL.md');
137+
if (existsSync(directSkill)) {
138+
return [await validateSkillPath(skillPath)];
139+
}
140+
141+
// Multi-skill mode: scan subdirectories for SKILL.md files
142+
const entries = await readdir(resolved, { withFileTypes: true });
143+
const skillPaths: string[] = [];
144+
145+
for (const entry of entries) {
146+
if (!entry.isDirectory()) continue;
147+
const subSkill = join(resolved, entry.name, 'SKILL.md');
148+
if (existsSync(subSkill)) {
149+
skillPaths.push(subSkill);
150+
}
151+
}
152+
153+
if (skillPaths.length === 0) {
154+
throw new Error(`No SKILL.md files found in directory: ${skillPath}`);
155+
}
156+
157+
return skillPaths;
158+
}
159+
76160
/**
77161
* Validate skill path for security and correctness
78162
* Prevents path traversal attacks and ensures path points to valid SKILL.md
@@ -165,13 +249,22 @@ async function buildConfig(options: LintOptions): Promise<LintConfig> {
165249
// CLI flags override file config
166250
const overrides: Record<string, unknown> = {};
167251

168-
if (options.structure !== undefined || options.triggering !== undefined ||
169-
options.performance !== undefined || options.integration !== undefined) {
252+
const hasScenarioFlag = options.structure !== undefined || options.size !== undefined ||
253+
options.references !== undefined || options.links !== undefined ||
254+
options.keywords !== undefined || options.harness !== undefined ||
255+
options.triggering !== undefined || options.performance !== undefined ||
256+
options.integration !== undefined;
257+
258+
if (hasScenarioFlag) {
170259
overrides.scenarios = {
171260
structure: options.structure ?? fileConfig.scenarios.structure,
172-
triggering: options.triggering ?? fileConfig.scenarios.triggering,
173-
performance: options.performance ?? fileConfig.scenarios.performance,
174-
integration: options.integration ?? fileConfig.scenarios.integration,
261+
size: (options.size ?? options.performance) ?? fileConfig.scenarios.size,
262+
references: options.references ?? fileConfig.scenarios.references,
263+
links: options.links !== undefined
264+
? { enabled: options.links, checkExternal: fileConfig.scenarios.links?.checkExternal ?? false }
265+
: fileConfig.scenarios.links,
266+
keywords: (options.keywords ?? options.triggering) ?? fileConfig.scenarios.keywords,
267+
harness: (options.harness ?? options.integration) ?? fileConfig.scenarios.harness,
175268
};
176269
}
177270

plugins/ui5/skill-lint/src/cli/index.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,20 @@ export function createCLI(): Command {
2424
.option('-f, --format <format>', 'Output format: text, json, github-actions', 'text')
2525
.option('-o, --output <path>', 'Write report to file')
2626
.option('--structure', 'Run structure validation')
27-
.option('--triggering', 'Run triggering simulation')
28-
.option('--performance', 'Run performance checks')
29-
.option('--integration', 'Run integration tests (requires adapter)')
27+
.option('--size', 'Run size checks')
28+
.option('--references', 'Run reference file analysis')
29+
.option('--links', 'Run link validation')
30+
.option('--keywords', 'Run keyword/triggering simulation')
31+
.option('--harness', 'Run harness tests (requires adapter)')
32+
// Backward compat flags
33+
.option('--triggering', 'Run keyword simulation (alias for --keywords)')
34+
.option('--performance', 'Run size checks (alias for --size)')
35+
.option('--integration', 'Run harness tests (alias for --harness)')
3036
.option('--no-structure', 'Skip structure validation')
31-
.option('--no-triggering', 'Skip triggering simulation')
32-
.option('--no-performance', 'Skip performance checks')
37+
.option('--no-size', 'Skip size checks')
38+
.option('--no-references', 'Skip reference analysis')
39+
.option('--no-links', 'Skip link validation')
40+
.option('--no-keywords', 'Skip keyword simulation')
3341
.option('-v, --verbose', 'Verbose output')
3442
.action(async (path: string, options) => {
3543
const exitCode = await lintCommand(path, options);

plugins/ui5/skill-lint/src/config/schema.ts

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,80 @@ import { z } from 'zod';
66
import type { LintConfig } from '../types/index.js';
77

88
export const lintConfigSchema = z.object({
9-
scenarios: z.object({
10-
structure: z.boolean().default(true),
11-
triggering: z.boolean().default(true),
12-
performance: z.boolean().default(true),
13-
integration: z.boolean().default(false),
14-
}).default({}),
9+
scenarios: z.preprocess(
10+
(val) => {
11+
if (typeof val !== 'object' || val === null) return val;
12+
const raw = val as Record<string, unknown>;
13+
const result = { ...raw };
14+
// Map old names → new before Zod applies defaults
15+
if (raw.performance !== undefined && raw.size === undefined) {
16+
result.size = raw.performance;
17+
}
18+
if (raw.triggering !== undefined && raw.keywords === undefined) {
19+
result.keywords = raw.triggering;
20+
}
21+
if (raw.integration !== undefined && raw.harness === undefined) {
22+
result.harness = raw.integration;
23+
}
24+
return result;
25+
},
26+
z.object({
27+
structure: z.boolean().default(true),
28+
size: z.boolean().default(true),
29+
references: z.boolean().default(true),
30+
links: z.preprocess(
31+
(val) => {
32+
if (typeof val === 'boolean') return { enabled: val };
33+
if (typeof val === 'object' && val !== null) return val;
34+
return { enabled: true };
35+
},
36+
z.object({
37+
enabled: z.boolean().default(true),
38+
checkExternal: z.boolean().default(false),
39+
}).default({ enabled: true, checkExternal: false }),
40+
),
41+
keywords: z.boolean().default(true),
42+
harness: z.boolean().default(false),
43+
// Keep old names passthrough for backward compat (not used after transform)
44+
performance: z.boolean().optional(),
45+
triggering: z.boolean().optional(),
46+
integration: z.boolean().optional(),
47+
}).default({}),
48+
),
1549

1650
adapter: z.string().default('claude-code'),
1751

18-
thresholds: z.object({
19-
performance: z.object({
20-
maxLines: z.number().positive().default(700),
21-
maxTokens: z.number().positive().default(4000),
52+
thresholds: z.preprocess(
53+
(val) => {
54+
if (typeof val !== 'object' || val === null) return val;
55+
const raw = val as Record<string, unknown>;
56+
const result = { ...raw };
57+
if (raw.performance && !raw.size) {
58+
result.size = raw.performance;
59+
}
60+
if (raw.triggering && !raw.keywords) {
61+
result.keywords = raw.triggering;
62+
}
63+
return result;
64+
},
65+
z.object({
66+
size: z.object({
67+
maxLines: z.number().positive().default(700),
68+
maxTokens: z.number().positive().default(4000),
69+
}).default({}),
70+
keywords: z.object({
71+
minAccuracy: z.number().min(0).max(100).default(90),
72+
}).default({}),
73+
// Backward compat aliases (passthrough)
74+
performance: z.object({
75+
maxLines: z.number().positive().default(700),
76+
maxTokens: z.number().positive().default(4000),
77+
}).optional(),
78+
triggering: z.object({
79+
minAccuracy: z.number().min(0).max(100).default(90),
80+
}).optional(),
2281
}).default({}),
23-
triggering: z.object({
24-
minAccuracy: z.number().min(0).max(100).default(90),
25-
}).default({}),
26-
}).default({}),
82+
),
2783

2884
testCases: z.object({
2985
triggering: z.string().optional(),

plugins/ui5/skill-lint/src/core/linter.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
*/
44

55
import { StructureValidator } from '../validators/structure-validator.js';
6-
import { PerformanceValidator } from '../validators/performance-validator.js';
7-
import { TriggeringValidator } from '../validators/triggering-validator.js';
8-
import { IntegrationValidator } from '../validators/integration-validator.js';
6+
import { SizeValidator } from '../validators/size-validator.js';
7+
import { ReferenceValidator } from '../validators/reference-validator.js';
8+
import { LinkValidator } from '../validators/link-validator.js';
9+
import { KeywordValidator } from '../validators/keyword-validator.js';
10+
import { HarnessValidator } from '../validators/harness-validator.js';
911
import { BaseValidator } from '../validators/base-validator.js';
1012
import { collectResults } from './result-collector.js';
1113
import { loadSkill } from '../utils/file-utils.js';
@@ -20,9 +22,11 @@ export class SkillLinter {
2022
const validators: BaseValidator[] = [];
2123

2224
if (config.scenarios.structure) validators.push(new StructureValidator());
23-
if (config.scenarios.performance) validators.push(new PerformanceValidator());
24-
if (config.scenarios.triggering) validators.push(new TriggeringValidator());
25-
if (config.scenarios.integration) validators.push(new IntegrationValidator());
25+
if (config.scenarios.size) validators.push(new SizeValidator());
26+
if (config.scenarios.references) validators.push(new ReferenceValidator());
27+
if (config.scenarios.links?.enabled) validators.push(new LinkValidator());
28+
if (config.scenarios.keywords) validators.push(new KeywordValidator());
29+
if (config.scenarios.harness) validators.push(new HarnessValidator());
2630

2731
this.validators = validators;
2832
}

plugins/ui5/skill-lint/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,12 @@ export { ClaudeCodeAdapter } from './adapters/claude-code-adapter.js';
1313
export { getAdapter, listAdapters } from './adapters/adapter-registry.js';
1414
export { createCLI } from './cli/index.js';
1515

16+
// Validators
17+
export { StructureValidator } from './validators/structure-validator.js';
18+
export { SizeValidator } from './validators/size-validator.js';
19+
export { ReferenceValidator } from './validators/reference-validator.js';
20+
export { LinkValidator } from './validators/link-validator.js';
21+
export { KeywordValidator } from './validators/keyword-validator.js';
22+
export { HarnessValidator } from './validators/harness-validator.js';
23+
1624
export type * from './types/index.js';

plugins/ui5/skill-lint/src/types/index.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,17 +147,34 @@ export type ProgressCallback = (event: ProgressEvent) => void;
147147
export interface LintConfig {
148148
readonly scenarios: {
149149
readonly structure: boolean;
150-
readonly triggering: boolean;
151-
readonly performance: boolean;
152-
readonly integration: boolean;
150+
readonly size: boolean;
151+
readonly references: boolean;
152+
readonly links: {
153+
readonly enabled: boolean;
154+
readonly checkExternal: boolean;
155+
};
156+
readonly keywords: boolean;
157+
readonly harness: boolean;
158+
// Backward compat (optional, mapped by Zod transform)
159+
readonly performance?: boolean;
160+
readonly triggering?: boolean;
161+
readonly integration?: boolean;
153162
};
154163
readonly adapter: string;
155164
readonly thresholds: {
156-
readonly performance: {
165+
readonly size: {
166+
readonly maxLines: number;
167+
readonly maxTokens: number;
168+
};
169+
readonly keywords: {
170+
readonly minAccuracy: number;
171+
};
172+
// Backward compat
173+
readonly performance?: {
157174
readonly maxLines: number;
158175
readonly maxTokens: number;
159176
};
160-
readonly triggering: {
177+
readonly triggering?: {
161178
readonly minAccuracy: number;
162179
};
163180
};

0 commit comments

Comments
 (0)