From 79128df0e5e82db47c0efbbdfaa95acec83a74ad Mon Sep 17 00:00:00 2001 From: Kevin Moore Date: Thu, 15 Oct 2026 01:21:23 +0000 Subject: [PATCH 1/3] feat: add skills.sh.json grouping and validation test - Add `skills.sh.json` grouping the 10 Flutter skills (`UI & Layout`, `Architecture & Data`, `Testing & Previews`) and 15 synced Dart skills (`Dart Language`, `Dart Testing`, `Dart Analysis`, `Dart Interop`) while setting `notGrouped: "bottom"` to demote 64 legacy/renamed slugs on `skills.sh/flutter/agent-plugins`. - Extend `tool/generator/test/lint_skills_test.dart` and `.github/workflows/skills_tool.yaml` to verify that `skills.sh.json` and `README.md` stay synchronized with `skills/`. --- .github/workflows/skills_tool.yaml | 4 ++ skills.sh.json | 73 +++++++++++++++++++++++ tool/generator/test/lint_skills_test.dart | 73 +++++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 skills.sh.json diff --git a/.github/workflows/skills_tool.yaml b/.github/workflows/skills_tool.yaml index 30f433a2..d0921212 100644 --- a/.github/workflows/skills_tool.yaml +++ b/.github/workflows/skills_tool.yaml @@ -7,14 +7,18 @@ on: pull_request: paths: - '.github/workflows/skills_tool.yaml' + - 'README.md' - 'pubspec.yaml' + - 'skills.sh.json' - 'skills/**' - 'tool/generator/**' push: branches: [ main ] paths: - '.github/workflows/skills_tool.yaml' + - 'README.md' - 'pubspec.yaml' + - 'skills.sh.json' - 'skills/**' - 'tool/generator/**' schedule: diff --git a/skills.sh.json b/skills.sh.json new file mode 100644 index 00000000..1dff90ea --- /dev/null +++ b/skills.sh.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://skills.sh/schemas/skills.sh.json", + "notGrouped": "bottom", + "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" + ] + }, + { + "title": "Dart Language", + "description": "Modern Dart syntax, primary constructors, pattern matching, and API documentation.", + "skills": [ + "dart-use-pattern-matching", + "dart-use-primary-constructors", + "dart-write-documentation", + "dart-use-doc-examples" + ] + }, + { + "title": "Dart Testing", + "description": "Unit test authoring, mock generation, code coverage, and package:checks migrations.", + "skills": [ + "dart-add-unit-test", + "dart-generate-test-mocks", + "dart-collect-coverage", + "dart-migrate-to-checks-package" + ] + }, + { + "title": "Dart Analysis", + "description": "Static analysis, automated lint fixes, runtime error debugging, and dependency resolution.", + "skills": [ + "dart-run-static-analysis", + "dart-fix-runtime-errors", + "dart-resolve-package-conflicts" + ] + }, + { + "title": "Dart Interop", + "description": "Command-line app architecture, cross-platform paths, FFI bindings, and native assets.", + "skills": [ + "dart-build-cli-app", + "dart-use-path-package", + "dart-use-ffigen", + "dart-setup-ffi-assets" + ] + } + ] +} diff --git a/tool/generator/test/lint_skills_test.dart b/tool/generator/test/lint_skills_test.dart index 0da3a4ca..5b4eef4e 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'; @@ -10,6 +11,78 @@ import 'package:skills_lint/skills_lint.dart'; import 'package:test/test.dart'; void main() { + test('Validate skills.sh.json', () { + final currentPath = Directory.current.path; + final repoRoot = currentPath.endsWith(p.join('tool', 'generator')) + ? Directory.current.parent.parent + : Directory.current; + final skillsDir = Directory(p.join(repoRoot.path, 'skills')); + expect(skillsDir.existsSync(), isTrue); + + final actualSkills = skillsDir + .listSync() + .whereType() + .map((d) => p.basename(d.path)) + .where((name) => !name.startsWith('.')) + .toSet(); + expect(actualSkills, isNotEmpty); + + 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'], + 'https://skills.sh/schemas/skills.sh.json', + ); + expect(skillsShJson['notGrouped'], 'bottom'); + + final groups = (skillsShJson['groups'] as List) + .cast>(); + expect(groups, isNotEmpty); + + final groupedSkills = {}; + for (final group in groups) { + final title = group['title']; + expect( + title, + isA().having( + (s) => + s.trim().isNotEmpty && s.trim().split(RegExp(r'\s+')).length <= 3, + '1-3 words', + isTrue, + ), + ); + expect( + group['description'], + isA().having((s) => s.isNotEmpty, 'non-empty', isTrue), + ); + 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', + ); + } + } + + expect(groupedSkills, equals(actualSkills)); + + 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(); + final actualFlutterSkills = actualSkills + .where((s) => s.startsWith('flutter-')) + .toSet(); + expect(readmeSkills, equals(actualFlutterSkills)); + }); + test('Run skills linter', () async { final lintErrors = []; Logger.root.level = Level.ALL; From d42bbb1d4de7c1a3372e03bb93f3c1e178ff45b2 Mon Sep 17 00:00:00 2001 From: Kevin Moore Date: Thu, 15 Oct 2026 17:21:48 +0000 Subject: [PATCH 2/3] fix: group only flutter-* skills in skills.sh.json and document validation - Remove the 4 synced `Dart *` groups from `skills.sh.json` so only the 10 `flutter-*` skills are grouped at the top (`UI & Layout`, `Architecture & Data`, `Testing & Previews`) and synced `dart-*` skills fall into `notGrouped: "bottom"` alongside retired legacy slugs. - Place `"notGrouped": "bottom"` at the end of `skills.sh.json`. - Remove the `paths:` filter on `pull_request` and `push` in `.github/workflows/skills_tool.yaml`. - Clarify validation steps and local string comparison (zero network calls) in `tool/generator/test/lint_skills_test.dart`. - Document how to update `README.md` and `skills.sh.json` when adding, renaming, or deleting skills in `tool/generator/README.md`. --- .github/workflows/skills_tool.yaml | 14 -------- skills.sh.json | 43 ++--------------------- tool/generator/README.md | 10 ++++++ tool/generator/test/lint_skills_test.dart | 37 ++++++++++++------- 4 files changed, 37 insertions(+), 67 deletions(-) diff --git a/.github/workflows/skills_tool.yaml b/.github/workflows/skills_tool.yaml index d0921212..1a89d9d7 100644 --- a/.github/workflows/skills_tool.yaml +++ b/.github/workflows/skills_tool.yaml @@ -5,22 +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' - - 'README.md' - - 'pubspec.yaml' - - 'skills.sh.json' - - 'skills/**' - - 'tool/generator/**' push: branches: [ main ] - paths: - - '.github/workflows/skills_tool.yaml' - - 'README.md' - - 'pubspec.yaml' - - 'skills.sh.json' - - 'skills/**' - - 'tool/generator/**' schedule: - cron: '0 0 * * 0' # weekly diff --git a/skills.sh.json b/skills.sh.json index 1dff90ea..4b0e9438 100644 --- a/skills.sh.json +++ b/skills.sh.json @@ -1,6 +1,5 @@ { "$schema": "https://skills.sh/schemas/skills.sh.json", - "notGrouped": "bottom", "groups": [ { "title": "UI & Layout", @@ -29,45 +28,7 @@ "flutter-add-integration-test", "flutter-add-widget-preview" ] - }, - { - "title": "Dart Language", - "description": "Modern Dart syntax, primary constructors, pattern matching, and API documentation.", - "skills": [ - "dart-use-pattern-matching", - "dart-use-primary-constructors", - "dart-write-documentation", - "dart-use-doc-examples" - ] - }, - { - "title": "Dart Testing", - "description": "Unit test authoring, mock generation, code coverage, and package:checks migrations.", - "skills": [ - "dart-add-unit-test", - "dart-generate-test-mocks", - "dart-collect-coverage", - "dart-migrate-to-checks-package" - ] - }, - { - "title": "Dart Analysis", - "description": "Static analysis, automated lint fixes, runtime error debugging, and dependency resolution.", - "skills": [ - "dart-run-static-analysis", - "dart-fix-runtime-errors", - "dart-resolve-package-conflicts" - ] - }, - { - "title": "Dart Interop", - "description": "Command-line app architecture, cross-platform paths, FFI bindings, and native assets.", - "skills": [ - "dart-build-cli-app", - "dart-use-path-package", - "dart-use-ffigen", - "dart-setup-ffi-assets" - ] } - ] + ], + "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 5b4eef4e..2d751a57 100644 --- a/tool/generator/test/lint_skills_test.dart +++ b/tool/generator/test/lint_skills_test.dart @@ -10,34 +10,48 @@ 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'; + void main() { - test('Validate skills.sh.json', () { + 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 actualSkills = skillsDir + final actualFlutterSkills = skillsDir .listSync() .whereType() .map((d) => p.basename(d.path)) - .where((name) => !name.startsWith('.')) + .where((name) => name.startsWith('flutter-')) .toSet(); - expect(actualSkills, isNotEmpty); + 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'], - 'https://skills.sh/schemas/skills.sh.json', - ); + expect(skillsShJson[r'$schema'], _expectedSkillsShSchema); + + // Setting notGrouped to "bottom" pushes any skill not listed in "groups" + // (including previously deleted/renamed skill slugs retained in skills.sh + // install telemetry and synced dart-* copies) into an "Other skills" + // section at the bottom of the skills.sh repository page. expect(skillsShJson['notGrouped'], 'bottom'); + expect(skillsShJson.keys.last, 'notGrouped'); + // 3. Verify each group has a 1-3 word title, a non-empty description, and + // unique skill identifiers that match actualFlutterSkills. final groups = (skillsShJson['groups'] as List) .cast>(); expect(groups, isNotEmpty); @@ -69,17 +83,16 @@ void main() { } } - expect(groupedSkills, equals(actualSkills)); + 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(); - final actualFlutterSkills = actualSkills - .where((s) => s.startsWith('flutter-')) - .toSet(); expect(readmeSkills, equals(actualFlutterSkills)); }); From 5bcd74dbadc2295ae15748a8e228a84c723aded8 Mon Sep 17 00:00:00 2001 From: Kevin Moore Date: Fri, 16 Oct 2026 16:40:43 +0000 Subject: [PATCH 3/3] test: extract validation helpers and simplify title check --- tool/generator/test/lint_skills_test.dart | 79 ++++++++++++++--------- 1 file changed, 49 insertions(+), 30 deletions(-) diff --git a/tool/generator/test/lint_skills_test.dart b/tool/generator/test/lint_skills_test.dart index 2d751a57..6c29d08f 100644 --- a/tool/generator/test/lint_skills_test.dart +++ b/tool/generator/test/lint_skills_test.dart @@ -12,6 +12,43 @@ 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/', () { @@ -43,46 +80,28 @@ void main() { jsonDecode(skillsShFile.readAsStringSync()) as Map; expect(skillsShJson[r'$schema'], _expectedSkillsShSchema); - // Setting notGrouped to "bottom" pushes any skill not listed in "groups" - // (including previously deleted/renamed skill slugs retained in skills.sh - // install telemetry and synced dart-* copies) into an "Other skills" - // section at the bottom of the skills.sh repository page. + // 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. Verify each group has a 1-3 word title, a non-empty description, and - // unique skill identifiers that match actualFlutterSkills. + // 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); - final groupedSkills = {}; for (final group in groups) { - final title = group['title']; - expect( - title, - isA().having( - (s) => - s.trim().isNotEmpty && s.trim().split(RegExp(r'\s+')).length <= 3, - '1-3 words', - isTrue, - ), - ); - expect( - group['description'], - isA().having((s) => s.isNotEmpty, 'non-empty', isTrue), - ); - 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', - ); - } + _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