import 'dart:io'; import 'package:yaml/yaml.dart'; const _platformOrder = ['mobile', 'desktop', 'web']; const _tierOrder = ['stable', 'beta', 'experimental']; const _buildModeOrder = ['debug', 'profile', 'release']; void main() { final manifest = FeatureManifest.load(); final git = GitSnapshot.capture(); _writeDoc( 'docs/plans/xworkmate-ui-feature-matrix.md', _renderFeatureMatrix(manifest, git), ); _writeDoc( 'docs/plans/xworkmate-ui-feature-roadmap.md', _renderFeatureRoadmap(manifest, git), ); _writeDoc( 'docs/releases/xworkmate-release-notes.md', _renderReleaseNotes(manifest, git), ); _writeDoc('docs/releases/xworkmate-changelog.md', _renderChangelog(git)); stdout.writeln( 'Rendered docs/plans/xworkmate-ui-feature-matrix.md, ' 'docs/plans/xworkmate-ui-feature-roadmap.md, ' 'docs/releases/xworkmate-release-notes.md, ' 'and docs/releases/xworkmate-changelog.md', ); } void _writeDoc(String relativePath, String contents) { final file = File(relativePath); file.parent.createSync(recursive: true); file.writeAsStringSync(contents); } String _renderFeatureMatrix(FeatureManifest manifest, GitSnapshot git) { final buffer = StringBuffer() ..writeln('# XWorkmate UI Feature Matrix') ..writeln() ..writeln(_generatedPreamble(git)) ..writeln() ..writeln('## Release Policy') ..writeln() ..writeln('| Build Mode | 可见 Tier | 说明 |') ..writeln('| --- | --- | --- |'); for (final buildMode in _buildModeOrder) { final tiers = manifest.releasePolicy[buildMode] ?? const []; final note = switch (buildMode) { 'debug' => '内部开发与功能联调', 'profile' => '预发布验收与性能验证', 'release' => '面向用户交付的正式版本', _ => '-', }; buffer.writeln( '| `${_escapeMarkdown(buildMode)}` | `${tiers.join(', ')}` | $note |', ); } buffer ..writeln() ..writeln( '`release_policy` 是全局上限;单个 flag 还必须同时满足 ' '`enabled: true` 和自身 `build_modes` 才会真正出现在 UI 中。', ) ..writeln() ..writeln('## Snapshot Summary') ..writeln() ..writeln( '| 平台 | Flag 总数 | 已启用 | Stable | Beta | Experimental | Disabled |', ) ..writeln('| --- | --- | --- | --- | --- | --- | --- |'); var total = 0; var totalEnabled = 0; var totalDisabled = 0; final tierTotals = {for (final tier in _tierOrder) tier: 0}; for (final platform in _platformOrder) { final records = manifest.recordsFor(platform); final enabled = records.where((record) => record.enabled).length; final disabled = records.length - enabled; total += records.length; totalEnabled += enabled; totalDisabled += disabled; final perTier = {for (final tier in _tierOrder) tier: 0}; for (final record in records.where((record) => record.enabled)) { perTier[record.releaseTier] = (perTier[record.releaseTier] ?? 0) + 1; tierTotals[record.releaseTier] = (tierTotals[record.releaseTier] ?? 0) + 1; } buffer.writeln( '| `${_escapeMarkdown(platform)}` | ${records.length} | $enabled | ' '${perTier['stable']} | ${perTier['beta']} | ' '${perTier['experimental']} | $disabled |', ); } buffer ..writeln( '| `total` | $total | $totalEnabled | ${tierTotals['stable']} | ' '${tierTotals['beta']} | ${tierTotals['experimental']} | $totalDisabled |', ) ..writeln(); for (final platform in _platformOrder) { buffer ..writeln('## ${_titleCase(platform)}') ..writeln() ..writeln('| 模块 | Flag | 状态 | Tier | Build Modes | UI Surface | 说明 |') ..writeln('| --- | --- | --- | --- | --- | --- | --- |'); for (final record in manifest.recordsFor(platform)) { final modes = record.buildModes.isEmpty ? '-' : _escapeMarkdown(record.buildModes.join(', ')); final state = record.enabled ? 'enabled' : 'disabled'; buffer.writeln( '| `${_escapeMarkdown(record.module)}` | ' '`${_escapeMarkdown(record.name)}` | $state | ' '`${_escapeMarkdown(record.releaseTier)}` | ' '`$modes` | ' '`${_escapeMarkdown(record.uiSurface)}` | ' '${_escapeMarkdown(record.description)} |', ); } buffer.writeln(); } return buffer.toString(); } String _renderFeatureRoadmap(FeatureManifest manifest, GitSnapshot git) { final buffer = StringBuffer() ..writeln('# XWorkmate UI Feature Flag Roadmap') ..writeln() ..writeln(_generatedPreamble(git)) ..writeln() ..writeln('## 规划规则') ..writeln() ..writeln( '- `release_policy` 决定 build mode 的总开关上限:`debug` 可见 ' '`stable / beta / experimental`,`profile` 可见 `stable / beta`,' '`release` 仅可见 `stable`。', ) ..writeln('- 单个 flag 的交付状态由三层共同决定:`enabled`、`release_tier`、`build_modes`。') ..writeln( '- `enabled: false` 或 `build_modes: []` 的项,会在文档里继续保留,' '但不会进入当前 build mode 的用户可见范围。', ) ..writeln() ..writeln('## Build Visibility Summary') ..writeln() ..writeln( '| 平台 | Debug Visible | Profile Visible | Release Visible | Suppressed |', ) ..writeln('| --- | --- | --- | --- | --- |'); for (final platform in _platformOrder) { final debugVisible = manifest.visibleFlags(platform, 'debug').length; final profileVisible = manifest.visibleFlags(platform, 'profile').length; final releaseVisible = manifest.visibleFlags(platform, 'release').length; final suppressed = manifest .recordsFor(platform) .where((record) => !record.visibleIn('debug', manifest.releasePolicy)) .length; buffer.writeln( '| `${_escapeMarkdown(platform)}` | $debugVisible | $profileVisible | ' '$releaseVisible | $suppressed |', ); } buffer ..writeln() ..writeln('## Release Baseline') ..writeln() ..writeln('| 平台 | 数量 | Flag 列表 |') ..writeln('| --- | --- | --- |'); for (final platform in _platformOrder) { final releaseFlags = manifest.visibleFlags(platform, 'release'); buffer.writeln( '| `${_escapeMarkdown(platform)}` | ${releaseFlags.length} | ' '${_flagList(releaseFlags)} |', ); } buffer ..writeln() ..writeln('## Profile-only Lane') ..writeln() ..writeln('| 平台 | 数量 | 相比 Release 新增 |') ..writeln('| --- | --- | --- |'); for (final platform in _platformOrder) { final profileOnly = _difference( manifest.visibleFlags(platform, 'profile'), manifest.visibleFlags(platform, 'release'), ); buffer.writeln( '| `${_escapeMarkdown(platform)}` | ${profileOnly.length} | ' '${_flagList(profileOnly)} |', ); } buffer ..writeln() ..writeln('## Debug-only Experimental Lane') ..writeln() ..writeln('| 平台 | 数量 | 相比 Profile 新增 |') ..writeln('| --- | --- | --- |'); for (final platform in _platformOrder) { final debugOnly = _difference( manifest.visibleFlags(platform, 'debug'), manifest.visibleFlags(platform, 'profile'), ); buffer.writeln( '| `${_escapeMarkdown(platform)}` | ${debugOnly.length} | ' '${_flagList(debugOnly)} |', ); } buffer ..writeln() ..writeln('## Explicitly Suppressed') ..writeln() ..writeln('| 平台 | 数量 | Flag 列表 |') ..writeln('| --- | --- | --- |'); for (final platform in _platformOrder) { final suppressed = manifest .recordsFor(platform) .where((record) => !record.visibleIn('debug', manifest.releasePolicy)) .toList(growable: false); buffer.writeln( '| `${_escapeMarkdown(platform)}` | ${suppressed.length} | ' '${_flagList(suppressed)} |', ); } buffer ..writeln() ..writeln('## Tier Inventory') ..writeln(); for (final platform in _platformOrder) { buffer.writeln('### ${_titleCase(platform)}'); buffer.writeln(); for (final tier in [..._tierOrder, 'disabled']) { final records = manifest .recordsFor(platform) .where((record) { if (!record.enabled) { return tier == 'disabled'; } return record.releaseTier == tier; }) .toList(growable: false); if (records.isEmpty) { continue; } buffer.writeln('- `$tier`: ${_flagList(records)}'); } buffer.writeln(); } return buffer.toString(); } String _renderReleaseNotes(FeatureManifest manifest, GitSnapshot git) { final profileOnlyAll = []; final debugOnlyAll = []; for (final platform in _platformOrder) { profileOnlyAll.addAll( _difference( manifest.visibleFlags(platform, 'profile'), manifest.visibleFlags(platform, 'release'), ), ); debugOnlyAll.addAll( _difference( manifest.visibleFlags(platform, 'debug'), manifest.visibleFlags(platform, 'profile'), ), ); } final categorized = _categorizeCommits(git.commits); final buffer = StringBuffer() ..writeln('# XWorkmate Release Notes') ..writeln() ..writeln(_generatedPreamble(git)) ..writeln() ..writeln('## Git Snapshot') ..writeln() ..writeln('| 字段 | 值 |') ..writeln('| --- | --- |') ..writeln('| Branch | `${_escapeMarkdown(git.branch)}` |') ..writeln('| Head Commit | `${_escapeMarkdown(git.headShort)}` |') ..writeln('| Head Tags | ${_inlineValue(git.headTags.join(', '))} |') ..writeln('| Latest Tag | ${_inlineValue(git.latestTag ?? '-')} |') ..writeln('| Previous Tag | ${_inlineValue(git.previousTag ?? '-')} |') ..writeln( '| Comparison Range | `${_escapeMarkdown(git.comparisonRangeLabel)}` |', ) ..writeln('| Commit Count | ${git.commits.length} |') ..writeln() ..writeln('## Feature Snapshot') ..writeln() ..writeln('| 平台 | Debug | Profile | Release | Suppressed |') ..writeln('| --- | --- | --- | --- | --- |'); for (final platform in _platformOrder) { buffer.writeln( '| `${_escapeMarkdown(platform)}` | ' '${manifest.visibleFlags(platform, 'debug').length} | ' '${manifest.visibleFlags(platform, 'profile').length} | ' '${manifest.visibleFlags(platform, 'release').length} | ' '${manifest.recordsFor(platform).where((record) => !record.visibleIn('debug', manifest.releasePolicy)).length} |', ); } buffer ..writeln() ..writeln('## Current Focus') ..writeln() ..writeln( '- `release` 当前面向用户暴露 ${manifest.visibleFlagCount('release')} 个 UI feature flags,' '全部来自 `stable` tier。', ) ..writeln( '- `profile` 相比 `release` 额外开放 ${profileOnlyAll.length} 个预发布条目:' ' ${_flagList(profileOnlyAll, includePlatform: true)}。', ) ..writeln( '- `debug` 相比 `profile` 额外开放 ${debugOnlyAll.length} 个实验条目:' ' ${_flagList(debugOnlyAll, includePlatform: true)}。', ) ..writeln() ..writeln('## Commit Highlights') ..writeln(); if (git.commits.isEmpty) { buffer.writeln('当前比较范围没有可渲染的 commits。'); return buffer.toString(); } for (final entry in categorized.entries) { if (entry.value.isEmpty) { continue; } buffer.writeln('### ${entry.key}'); buffer.writeln(); for (final commit in entry.value) { buffer.writeln( '- `${_escapeMarkdown(commit.hash)}` ${_escapeMarkdown(commit.subject)}', ); } buffer.writeln(); } return buffer.toString(); } String _renderChangelog(GitSnapshot git) { final buffer = StringBuffer() ..writeln('# XWorkmate Changelog') ..writeln() ..writeln(_generatedPreamble(git)) ..writeln() ..writeln('## Git Snapshot') ..writeln() ..writeln('| 字段 | 值 |') ..writeln('| --- | --- |') ..writeln('| Branch | `${_escapeMarkdown(git.branch)}` |') ..writeln('| Head Commit | `${_escapeMarkdown(git.headShort)}` |') ..writeln('| Head Tags | ${_inlineValue(git.headTags.join(', '))} |') ..writeln('| Latest Tag | ${_inlineValue(git.latestTag ?? '-')} |') ..writeln('| Previous Tag | ${_inlineValue(git.previousTag ?? '-')} |') ..writeln( '| Comparison Range | `${_escapeMarkdown(git.comparisonRangeLabel)}` |', ) ..writeln() ..writeln('## Recent Releases') ..writeln() ..writeln('| Version | Date | Branch | Tag |') ..writeln('| --- | --- | --- | --- |'); for (final release in git.recentReleases) { buffer.writeln( '| `${_escapeMarkdown(release.version)}` | ' '`${_escapeMarkdown(release.date)}` | ' '`${_escapeMarkdown(release.branch)}` | ' '`${_escapeMarkdown(release.tag)}` |', ); } buffer ..writeln() ..writeln('## Commits') ..writeln() ..writeln('| Hash | Date | Author | Subject |') ..writeln('| --- | --- | --- | --- |'); if (git.commits.isEmpty) { buffer.writeln( '| `-` | `-` | `-` | No commits found for the selected range |', ); return buffer.toString(); } for (final commit in git.commits) { buffer.writeln( '| `${_escapeMarkdown(commit.hash)}` | ' '`${_escapeMarkdown(commit.date)}` | ' '${_escapeMarkdown(commit.author)} | ' '${_escapeMarkdown(commit.subject)} |', ); } return buffer.toString(); } String _generatedPreamble(GitSnapshot git) { return [ '> Generated by `tool/render_release_docs.dart`', '> Source manifest: [`config/feature_flags.yaml`](../../config/feature_flags.yaml)', '> Generated at: `${_escapeMarkdown(git.generatedAt)}`', ].join('\n'); } String _flagList( List records, { bool includePlatform = false, }) { if (records.isEmpty) { return '-'; } return records .map( (record) => '`${_escapeMarkdown(includePlatform ? record.qualifiedId : record.id)}`', ) .join(', '); } List _difference( List left, List right, ) { final rightIds = right.map((record) => record.id).toSet(); return left .where((record) => !rightIds.contains(record.id)) .toList(growable: false); } Map> _categorizeCommits(List commits) { final ordered = >{ 'Features': [], 'Fixes': [], 'Build / Release': [], 'Docs': [], 'Tests': [], 'Refactors': [], 'Merges': [], 'Other': [], }; for (final commit in commits) { final subject = commit.subject.toLowerCase(); final bucket = switch (true) { _ when subject.startsWith('merge ') => 'Merges', _ when subject.startsWith('feat') || subject.startsWith('add ') || subject.startsWith('implement ') => 'Features', _ when subject.startsWith('fix') || subject.contains(' bug') => 'Fixes', _ when subject.startsWith('docs') || subject.startsWith('readme') => 'Docs', _ when subject.startsWith('test') => 'Tests', _ when subject.startsWith('refactor') => 'Refactors', _ when subject.startsWith('build') || subject.startsWith('release') || subject.startsWith('ci') || subject.startsWith('package') || subject.contains('workflow') => 'Build / Release', _ => 'Other', }; ordered[bucket]!.add(commit); } return ordered; } String _inlineValue(String value) { final normalized = value.trim().isEmpty ? '-' : value.trim(); return '`${_escapeMarkdown(normalized)}`'; } String _escapeMarkdown(String value) { return value.replaceAll('|', r'\|'); } String _titleCase(String value) { if (value.isEmpty) { return value; } return '${value[0].toUpperCase()}${value.substring(1)}'; } class FeatureManifest { FeatureManifest({required this.releasePolicy, required this.records}); factory FeatureManifest.load() { final yaml = loadYaml(File('config/feature_flags.yaml').readAsStringSync()); final root = yaml as YamlMap; final releasePolicyRoot = root['release_policy'] as YamlMap? ?? YamlMap(); final releasePolicy = >{ for (final buildMode in _buildModeOrder) buildMode: (releasePolicyRoot[buildMode] as YamlList? ?? YamlList()) .map((value) => value.toString()) .toList(growable: false), }; final records = []; for (final platform in _platformOrder) { final platformRoot = root[platform] as YamlMap?; if (platformRoot == null) { continue; } for (final moduleEntry in platformRoot.entries) { final module = moduleEntry.key.toString(); final featureRoot = moduleEntry.value as YamlMap; for (final featureEntry in featureRoot.entries) { final name = featureEntry.key.toString(); final raw = featureEntry.value as YamlMap; records.add( FeatureFlagRecord( platform: platform, module: module, name: name, enabled: raw['enabled'] == true, releaseTier: raw['release_tier'].toString(), buildModes: (raw['build_modes'] as YamlList? ?? YamlList()) .map((value) => value.toString()) .toList(growable: false), description: raw['description'].toString(), uiSurface: raw['ui_surface'].toString(), ), ); } } } return FeatureManifest(releasePolicy: releasePolicy, records: records); } final Map> releasePolicy; final List records; List recordsFor(String platform) { return records .where((record) => record.platform == platform) .toList(growable: false); } List visibleFlags(String platform, String buildMode) { return recordsFor(platform) .where((record) => record.visibleIn(buildMode, releasePolicy)) .toList(growable: false); } int visibleFlagCount(String buildMode) { return _platformOrder.fold( 0, (total, platform) => total + visibleFlags(platform, buildMode).length, ); } } class FeatureFlagRecord { const FeatureFlagRecord({ required this.platform, required this.module, required this.name, required this.enabled, required this.releaseTier, required this.buildModes, required this.description, required this.uiSurface, }); final String platform; final String module; final String name; final bool enabled; final String releaseTier; final List buildModes; final String description; final String uiSurface; String get id => '$module.$name'; String get qualifiedId => '$platform.$module.$name'; bool visibleIn(String buildMode, Map> releasePolicy) { final allowedTiers = releasePolicy[buildMode] ?? const []; return enabled && buildModes.contains(buildMode) && allowedTiers.contains(releaseTier); } } class GitSnapshot { GitSnapshot({ required this.branch, required this.headShort, required this.headLong, required this.headTags, required this.latestTag, required this.previousTag, required this.comparisonRangeLabel, required this.generatedAt, required this.commits, required this.recentTags, required this.recentReleases, }); factory GitSnapshot.capture() { final branch = _git(['branch', '--show-current'], allowFailure: true).trim().isEmpty ? 'detached-head' : _git(['branch', '--show-current']); final headShort = _git(['rev-parse', '--short', 'HEAD']); final headLong = _git(['rev-parse', 'HEAD']); final headTags = _gitLines(['tag', '--points-at', 'HEAD']); final allTags = _gitTagRefs(); final recentTags = allTags.take(5).toList(growable: false); final recentReleases = _gitReleaseRefs( allTags, ).take(8).toList(growable: false); final latestTag = recentTags.isEmpty ? null : recentTags.first.name; String? previousTag; String comparisonRangeLabel; String? comparisonRange; if (headTags.isNotEmpty) { previousTag = recentTags .map((tag) => tag.name) .firstWhere((tag) => !headTags.contains(tag), orElse: () => '') .trim(); if (previousTag.isEmpty) { previousTag = null; } final activeTag = headTags.first; comparisonRange = previousTag == null ? null : '$previousTag..$activeTag'; comparisonRangeLabel = comparisonRange ?? activeTag; } else if (latestTag != null) { previousTag = recentTags.length > 1 ? recentTags[1].name : null; comparisonRange = '$latestTag..HEAD'; comparisonRangeLabel = comparisonRange; } else { comparisonRange = null; comparisonRangeLabel = 'HEAD (latest 20 commits)'; } final commits = _gitCommitLog(comparisonRange); return GitSnapshot( branch: branch, headShort: headShort, headLong: headLong, headTags: headTags, latestTag: latestTag, previousTag: previousTag, comparisonRangeLabel: comparisonRangeLabel, generatedAt: DateTime.now().toIso8601String(), commits: commits, recentTags: recentTags, recentReleases: recentReleases, ); } final String branch; final String headShort; final String headLong; final List headTags; final String? latestTag; final String? previousTag; final String comparisonRangeLabel; final String generatedAt; final List commits; final List recentTags; final List recentReleases; } class GitCommit { const GitCommit({ required this.hash, required this.date, required this.author, required this.subject, }); final String hash; final String date; final String author; final String subject; } class GitTagRef { const GitTagRef({required this.name, required this.date}); final String name; final String date; } class GitReleaseRef { const GitReleaseRef({ required this.version, required this.date, required this.branch, required this.tag, }); final String version; final String date; final String branch; final String tag; } List _gitCommitLog(String? comparisonRange) { final args = [ 'log', '--date=short', '--pretty=format:%h%x09%ad%x09%an%x09%s', ]; if (comparisonRange == null) { args.addAll(['-n', '20']); } else { args.add(comparisonRange); } final lines = _gitLines(args, allowFailure: true); return lines .map((line) => line.split('\t')) .where((parts) => parts.length >= 4) .map( (parts) => GitCommit( hash: parts[0], date: parts[1], author: parts[2], subject: parts.sublist(3).join('\t'), ), ) .toList(growable: false); } List _gitTagRefs() { final lines = _gitLines([ 'for-each-ref', '--sort=-creatordate', '--format=%(refname:short)%09%(creatordate:short)', 'refs/tags', ], allowFailure: true); return lines .map((line) => line.split('\t')) .where((parts) => parts.length >= 2) .map((parts) => GitTagRef(name: parts[0], date: parts[1])) .toList(growable: false); } List _gitReleaseRefs(List tags) { final releases = {}; for (final tag in tags) { final version = _normalizeReleaseVersion(tag.name); if (version == null) { continue; } releases[version] = GitReleaseRef( version: version, date: tag.date, branch: _releaseBranchName(version), tag: tag.name, ); } final branchLines = _gitLines([ 'for-each-ref', '--sort=-committerdate', '--format=%(refname:short)%09%(committerdate:short)', 'refs/heads/release', ], allowFailure: true); for (final line in branchLines) { final parts = line.split('\t'); if (parts.length < 2) { continue; } final branch = parts[0]; final date = parts[1]; final version = _normalizeReleaseVersion(branch); if (version == null) { continue; } releases[version] = GitReleaseRef( version: version, date: releases[version]?.date ?? date, branch: branch, tag: releases[version]?.tag ?? '-', ); } final values = releases.values.toList(growable: false); values.sort( (left, right) => _compareReleaseVersions(right.version, left.version), ); return values; } String? _normalizeReleaseVersion(String refName) { final match = RegExp(r'^(?:release/)?(v\d+(?:\.\d+)*)$').firstMatch(refName); return match?.group(1); } String _releaseBranchName(String version) => 'release/$version'; int _compareReleaseVersions(String left, String right) { final leftParts = _releaseVersionParts(left); final rightParts = _releaseVersionParts(right); final maxLength = leftParts.length > rightParts.length ? leftParts.length : rightParts.length; for (var index = 0; index < maxLength; index += 1) { final leftPart = index < leftParts.length ? leftParts[index] : 0; final rightPart = index < rightParts.length ? rightParts[index] : 0; if (leftPart != rightPart) { return leftPart.compareTo(rightPart); } } return 0; } List _releaseVersionParts(String version) { return version .replaceFirst('v', '') .split('.') .map(int.parse) .toList(growable: false); } String _git(List args, {bool allowFailure = false}) { final result = Process.runSync('git', args); if (result.exitCode != 0) { if (allowFailure) { return ''; } throw ProcessException( 'git', args, (result.stderr as String).trim(), result.exitCode, ); } return (result.stdout as String).trim(); } List _gitLines(List args, {bool allowFailure = false}) { final output = _git(args, allowFailure: allowFailure); if (output.trim().isEmpty) { return const []; } return output .split('\n') .map((line) => line.trim()) .where((line) => line.isNotEmpty) .toList(growable: false); }