Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions .github/workflows/skills_tool.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions skills.sh.json
Original file line number Diff line number Diff line change
@@ -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"
}
10 changes: 10 additions & 0 deletions tool/generator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
105 changes: 105 additions & 0 deletions tool/generator/test/lint_skills_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,119 @@
// 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';
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<String>()
.having((s) => s.trim(), 'trimmed title', isNotEmpty)
.having(
(s) => s.length,
'length',
lessThanOrEqualTo(_maxGroupTitleLength),
),
);
}

void _validateGroupDescription(Object? description) {
expect(
description,
isA<String>().having((s) => s.trim(), 'trimmed description', isNotEmpty),
);
}

Set<String> _collectUniqueGroupedSkills(List<Map<String, Object?>> groups) {
final groupedSkills = <String>{};
for (final group in groups) {
final skills = (group['skills'] as List<Object?>).cast<String>();
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<Directory>()
.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<String, Object?>;
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<Object?>)
.cast<Map<String, Object?>>();
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 = <String>[];
Logger.root.level = Level.ALL;
Expand Down
Loading