diff --git a/.github/workflows/skills_tool.yaml b/.github/workflows/skills_tool.yaml index 30f433a2..1a89d9d7 100644 --- a/.github/workflows/skills_tool.yaml +++ b/.github/workflows/skills_tool.yaml @@ -5,18 +5,8 @@ permissions: on: # Run CI on all PRs (against any branch) and on pushes to the main branch. pull_request: - paths: - - '.github/workflows/skills_tool.yaml' - - 'pubspec.yaml' - - 'skills/**' - - 'tool/generator/**' push: branches: [ main ] - paths: - - '.github/workflows/skills_tool.yaml' - - 'pubspec.yaml' - - 'skills/**' - - 'tool/generator/**' schedule: - cron: '0 0 * * 0' # weekly diff --git a/skills.sh.json b/skills.sh.json new file mode 100644 index 00000000..4b0e9438 --- /dev/null +++ b/skills.sh.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://skills.sh/schemas/skills.sh.json", + "groups": [ + { + "title": "UI & Layout", + "description": "Responsive Flutter layouts, constraint debugging, and app localization.", + "skills": [ + "flutter-build-responsive-layout", + "flutter-fix-layout-issues", + "flutter-setup-localization" + ] + }, + { + "title": "Architecture & Data", + "description": "Layered app architecture, declarative routing, HTTP networking, and JSON serialization.", + "skills": [ + "flutter-apply-architecture-best-practices", + "flutter-setup-declarative-routing", + "flutter-use-http-package", + "flutter-implement-json-serialization" + ] + }, + { + "title": "Testing & Previews", + "description": "Widget tests, Flutter Driver integration tests, and interactive widget previews.", + "skills": [ + "flutter-add-widget-test", + "flutter-add-integration-test", + "flutter-add-widget-preview" + ] + } + ], + "notGrouped": "bottom" +} diff --git a/tool/generator/README.md b/tool/generator/README.md index 37e373aa..d637f3ea 100644 --- a/tool/generator/README.md +++ b/tool/generator/README.md @@ -139,6 +139,16 @@ The default configuration file is located at `resources/flutter_skills.yaml`. It - ../packages/flutter/lib/src/widgets/layout.md ``` +### Adding, Renaming, or Deleting Skills + +When adding, renaming, or deleting a `flutter-*` skill in `skills/`: + +1. **Update `README.md`**: Run `dart run skills update-readme` (or update the `Available Skills` table in the root `README.md`) so the table matches the `flutter-*` directories in `skills/`. +2. **Update `skills.sh.json`**: Add, rename, or remove the skill entry under `groups` in the root `skills.sh.json` file ([skills.sh documentation](https://www.skills.sh/docs/customize)). + * Only `flutter-*` skills belong in `groups` (synced `dart-*` skills are canonical in `dart-lang/skills`). + * Because `skills.sh` retains historical skill slugs from install telemetry after a skill is renamed or deleted in Git, `"notGrouped": "bottom"` at the end of `skills.sh.json` automatically demotes any retired skill slugs and ungrouped `dart-*` copies into an `Other skills` section at the bottom of the page. +3. **Verify**: Run `dart test` in `tool/generator` (`test/lint_skills_test.dart` verifies that `skills.sh.json` and `README.md` match the `flutter-*` directories in `skills/`). + ## Development To run analysis, formatting, and tests, navigate to this directory (`tool/generator`) and run: diff --git a/tool/generator/test/lint_skills_test.dart b/tool/generator/test/lint_skills_test.dart index 0da3a4ca..6c29d08f 100644 --- a/tool/generator/test/lint_skills_test.dart +++ b/tool/generator/test/lint_skills_test.dart @@ -2,6 +2,7 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'dart:convert'; import 'dart:io'; import 'package:logging/logging.dart'; @@ -9,7 +10,111 @@ import 'package:path/path.dart' as p; import 'package:skills_lint/skills_lint.dart'; import 'package:test/test.dart'; +const String _expectedSkillsShSchema = + 'https://skills.sh/schemas/skills.sh.json'; +const int _maxGroupTitleLength = 30; + +void _validateGroupTitle(Object? title) { + expect( + title, + isA() + .having((s) => s.trim(), 'trimmed title', isNotEmpty) + .having( + (s) => s.length, + 'length', + lessThanOrEqualTo(_maxGroupTitleLength), + ), + ); +} + +void _validateGroupDescription(Object? description) { + expect( + description, + isA().having((s) => s.trim(), 'trimmed description', isNotEmpty), + ); +} + +Set _collectUniqueGroupedSkills(List> groups) { + final groupedSkills = {}; + for (final group in groups) { + final skills = (group['skills'] as List).cast(); + expect(skills, isNotEmpty); + for (final skill in skills) { + expect( + groupedSkills.add(skill), + isTrue, + reason: 'Duplicate skill "$skill" in skills.sh.json', + ); + } + } + return groupedSkills; +} + void main() { + test('skills.sh.json and README.md match flutter-* skills in skills/', () { + final currentPath = Directory.current.path; + final repoRoot = currentPath.endsWith(p.join('tool', 'generator')) + ? Directory.current.parent.parent + : Directory.current; + + // 1. Collect all local flutter-* skill directories in skills/ (excluding + // synced dart-* skills, which are canonical in dart-lang/skills). + final skillsDir = Directory(p.join(repoRoot.path, 'skills')); + expect(skillsDir.existsSync(), isTrue); + + final actualFlutterSkills = skillsDir + .listSync() + .whereType() + .map((d) => p.basename(d.path)) + .where((name) => name.startsWith('flutter-')) + .toSet(); + expect(actualFlutterSkills, isNotEmpty); + + // 2. Parse skills.sh.json and validate its local properties (this performs + // a string comparison against _expectedSkillsShSchema and makes no HTTP or + // network calls). + final skillsShFile = File(p.join(repoRoot.path, 'skills.sh.json')); + expect(skillsShFile.existsSync(), isTrue); + + final skillsShJson = + jsonDecode(skillsShFile.readAsStringSync()) as Map; + expect(skillsShJson[r'$schema'], _expectedSkillsShSchema); + + // Setting notGrouped to "bottom" instructs skills.sh to render any skill + // not listed in "groups" (including previously deleted/renamed skill slugs + // retained in skills.sh install telemetry and synced dart-* copies) under + // its built-in "Other skills" heading at the bottom of the repository page + // (the schema only accepts "top", "bottom", or "hidden", and does not + // support a custom label). + expect(skillsShJson['notGrouped'], 'bottom'); + expect(skillsShJson.keys.last, 'notGrouped'); + + // 3. Validate each group's title, description, and unique skill + // identifiers, and ensure that all grouped skills exist as flutter-* + // directories in skills/ (without typos or missing skills). + final groups = (skillsShJson['groups'] as List) + .cast>(); + expect(groups, isNotEmpty); + + for (final group in groups) { + _validateGroupTitle(group['title']); + _validateGroupDescription(group['description']); + } + + final groupedSkills = _collectUniqueGroupedSkills(groups); + expect(groupedSkills, equals(actualFlutterSkills)); + + // 4. Verify the Available Skills table in README.md lists the exact same + // set of flutter-* skills. + final readmeFile = File(p.join(repoRoot.path, 'README.md')); + expect(readmeFile.existsSync(), isTrue); + final readmeMatches = RegExp( + r'\| \[([^\]]+)\]\(skills/\1/SKILL\.md\) \|', + ).allMatches(readmeFile.readAsStringSync()); + final readmeSkills = readmeMatches.map((m) => m.group(1)!).toSet(); + expect(readmeSkills, equals(actualFlutterSkills)); + }); + test('Run skills linter', () async { final lintErrors = []; Logger.root.level = Level.ALL;