com.wallstop-studios.dxmessaging 2.1.5 → 2.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/.artifacts/SourceGenerators.Tests/obj/Debug/net9.0/WallstopStudios.DxMessaging.SourceGenerators.Tests.AssemblyInfo.cs +1 -1
  2. package/.cspell.json +4 -1
  3. package/.github/workflows/actionlint.yml +11 -1
  4. package/.github/workflows/csharpier-autofix.yml +34 -3
  5. package/.github/workflows/dotnet-tests.yml +13 -0
  6. package/.github/workflows/format-on-demand.yml +38 -44
  7. package/.github/workflows/json-format-check.yml +24 -0
  8. package/.github/workflows/lint-doc-links.yml +13 -0
  9. package/.github/workflows/markdown-json.yml +21 -4
  10. package/.github/workflows/markdown-link-text-check.yml +10 -0
  11. package/.github/workflows/markdown-link-validity.yml +10 -0
  12. package/.github/workflows/markdownlint.yml +7 -5
  13. package/.github/workflows/prettier-autofix.yml +67 -11
  14. package/.github/workflows/release-drafter.yml +2 -2
  15. package/.github/workflows/sync-wiki.yml +3 -3
  16. package/.github/workflows/yaml-format-lint.yml +26 -0
  17. package/.llm/context.md +113 -3
  18. package/.llm/skills/documentation/changelog-management.md +38 -0
  19. package/.llm/skills/documentation/documentation-style-guide.md +18 -0
  20. package/.llm/skills/documentation/documentation-update-workflow.md +2 -0
  21. package/.llm/skills/documentation/documentation-updates.md +2 -0
  22. package/.llm/skills/documentation/markdown-compatibility.md +476 -0
  23. package/.llm/skills/documentation/mermaid-theming.md +326 -0
  24. package/.llm/skills/documentation/mkdocs-navigation.md +290 -0
  25. package/.llm/skills/github-actions/git-renormalize-patterns.md +231 -0
  26. package/.llm/skills/github-actions/workflow-consistency.md +346 -0
  27. package/.llm/skills/index.md +53 -27
  28. package/.llm/skills/scripting/javascript-code-quality.md +417 -0
  29. package/.llm/skills/scripting/regex-documentation.md +461 -0
  30. package/.llm/skills/scripting/shell-best-practices.md +55 -4
  31. package/.llm/skills/scripting/validation-patterns.md +418 -0
  32. package/.llm/skills/specification.md +4 -1
  33. package/.llm/skills/testing/test-code-quality.md +243 -0
  34. package/.llm/skills/testing/test-production-code.md +348 -0
  35. package/CHANGELOG.md +11 -0
  36. package/README.md +0 -11
  37. package/Tests/Runtime/Benchmarks/WallstopStudios.DxMessaging.Tests.Runtime.Benchmarks.asmdef +1 -6
  38. package/Tests/Runtime/Integrations/Reflex/WallstopStudios.DxMessaging.Tests.Runtime.Reflex.asmdef +1 -1
  39. package/Tests/Runtime/Integrations/VContainer/WallstopStudios.DxMessaging.Tests.Runtime.VContainer.asmdef +1 -1
  40. package/Tests/Runtime/Integrations/Zenject/WallstopStudios.DxMessaging.Tests.Runtime.Zenject.asmdef +1 -1
  41. package/coverage/clover.xml +216 -3
  42. package/coverage/clover.xml.meta +7 -7
  43. package/coverage/coverage-final.json +2 -1
  44. package/coverage/coverage-final.json.meta +7 -7
  45. package/coverage/lcov-report/base.css.meta +1 -1
  46. package/coverage/lcov-report/block-navigation.js.meta +1 -1
  47. package/coverage/lcov-report/favicon.png.meta +1 -1
  48. package/coverage/lcov-report/index.html +25 -10
  49. package/coverage/lcov-report/index.html.meta +7 -7
  50. package/coverage/lcov-report/prettify.css.meta +1 -1
  51. package/coverage/lcov-report/prettify.js.meta +1 -1
  52. package/coverage/lcov-report/sort-arrow-sprite.png.meta +1 -1
  53. package/coverage/lcov-report/sorter.js.meta +1 -1
  54. package/coverage/lcov-report/transform-docs-to-wiki.js.html +1 -1
  55. package/coverage/lcov-report/transform-docs-to-wiki.js.html.meta +7 -7
  56. package/coverage/lcov-report/vendor.meta +1 -1
  57. package/coverage/lcov-report.meta +8 -8
  58. package/coverage/lcov.info +365 -0
  59. package/coverage/lcov.info.meta +7 -7
  60. package/docs/architecture/design-and-architecture.md +0 -1
  61. package/docs/concepts/index.md +37 -0
  62. package/docs/concepts/index.md.meta +7 -0
  63. package/docs/concepts/interceptors-and-ordering.md +0 -2
  64. package/docs/concepts/mental-model.md +390 -0
  65. package/docs/concepts/mental-model.md.meta +7 -0
  66. package/docs/concepts/message-types.md +0 -1
  67. package/docs/getting-started/getting-started.md +1 -0
  68. package/docs/getting-started/index.md +6 -5
  69. package/docs/getting-started/overview.md +1 -0
  70. package/docs/getting-started/quick-start.md +2 -1
  71. package/docs/getting-started/visual-guide.md +4 -10
  72. package/docs/hooks.py +10 -1
  73. package/docs/images/DxMessaging-banner.svg +1 -1
  74. package/docs/index.md +7 -7
  75. package/docs/javascripts/mermaid-config.js +44 -4
  76. package/docs/reference/helpers.md +130 -154
  77. package/docs/reference/quick-reference.md +5 -1
  78. package/docs/reference/reference.md +124 -130
  79. package/mkdocs.yml +2 -0
  80. package/package.json +1 -1
  81. package/scripts/__tests__/generate-skills-index.test.js +397 -0
  82. package/scripts/__tests__/generate-skills-index.test.js.meta +7 -0
  83. package/scripts/__tests__/mermaid-config.test.js +467 -0
  84. package/scripts/__tests__/mermaid-config.test.js.meta +7 -0
  85. package/scripts/__tests__/validate-skills-optional-fields.test.js +1474 -0
  86. package/scripts/__tests__/validate-skills-optional-fields.test.js.meta +7 -0
  87. package/scripts/__tests__/validate-skills-required-fields.test.js +188 -0
  88. package/scripts/__tests__/validate-skills-required-fields.test.js.meta +7 -0
  89. package/scripts/__tests__/validate-skills-tags.test.js +353 -0
  90. package/scripts/__tests__/validate-skills-tags.test.js.meta +7 -0
  91. package/scripts/__tests__/validate-workflows.test.js +188 -0
  92. package/scripts/__tests__/validate-workflows.test.js.meta +7 -0
  93. package/scripts/generate-skills-index.js +88 -3
  94. package/scripts/validate-skills.js +230 -30
  95. package/scripts/validate-workflows.js +272 -0
  96. package/scripts/validate-workflows.js.meta +7 -0
  97. package/site/404.html +1 -1
  98. package/site/advanced/emit-shorthands/index.html +2 -2
  99. package/site/advanced/message-bus-providers/index.html +2 -2
  100. package/site/advanced/registration-builders/index.html +2 -2
  101. package/site/advanced/runtime-configuration/index.html +2 -2
  102. package/site/advanced/string-messages/index.html +2 -2
  103. package/site/advanced.meta +1 -1
  104. package/site/architecture/comparisons/index.html +2 -2
  105. package/site/architecture/design-and-architecture/index.html +2 -2
  106. package/site/architecture/performance/index.html +1 -1
  107. package/site/architecture.meta +1 -1
  108. package/site/concepts/index.html +1 -0
  109. package/site/concepts/index.html.meta +7 -0
  110. package/site/concepts/interceptors-and-ordering/index.html +4 -4
  111. package/site/concepts/listening-patterns/index.html +2 -2
  112. package/site/concepts/mental-model/index.html +146 -0
  113. package/site/concepts/mental-model/index.html.meta +7 -0
  114. package/site/concepts/mental-model.meta +8 -0
  115. package/site/concepts/message-types/index.html +2 -2
  116. package/site/concepts/targeting-and-context/index.html +2 -2
  117. package/site/concepts.meta +1 -1
  118. package/site/examples/end-to-end/index.html +2 -2
  119. package/site/examples/end-to-end-scene-transitions/index.html +2 -2
  120. package/site/examples.meta +1 -1
  121. package/site/getting-started/getting-started/index.html +3 -3
  122. package/site/getting-started/index.html +4 -4
  123. package/site/getting-started/install/index.html +3 -3
  124. package/site/getting-started/overview/index.html +2 -2
  125. package/site/getting-started/quick-start/index.html +2 -2
  126. package/site/getting-started/visual-guide/index.html +11 -11
  127. package/site/getting-started.meta +1 -1
  128. package/site/guides/advanced/index.html +2 -2
  129. package/site/guides/diagnostics/index.html +2 -2
  130. package/site/guides/migration-guide/index.html +2 -2
  131. package/site/guides/patterns/index.html +2 -2
  132. package/site/guides/testing/index.html +2 -2
  133. package/site/guides/unity-integration/index.html +2 -2
  134. package/site/guides.meta +1 -1
  135. package/site/hooks.py.meta +1 -1
  136. package/site/images/DxMessaging-banner.svg +119 -0
  137. package/site/images/DxMessaging-banner.svg.meta +7 -0
  138. package/site/images.meta +8 -0
  139. package/site/index.html +2 -2
  140. package/site/integrations/index.html +2 -2
  141. package/site/integrations/reflex/index.html +2 -2
  142. package/site/integrations/vcontainer/index.html +2 -2
  143. package/site/integrations/zenject/index.html +2 -2
  144. package/site/integrations.meta +1 -1
  145. package/site/javascripts/csharp-highlight.js.meta +7 -7
  146. package/site/javascripts/mermaid-config.js +4 -1
  147. package/site/javascripts/mermaid-config.js.meta +1 -1
  148. package/site/javascripts.meta +1 -1
  149. package/site/reference/compatibility/index.html +1 -1
  150. package/site/reference/faq/index.html +1 -1
  151. package/site/reference/glossary/index.html +2 -2
  152. package/site/reference/helpers/index.html +15 -15
  153. package/site/reference/quick-reference/index.html +3 -3
  154. package/site/reference/reference/index.html +37 -37
  155. package/site/reference/troubleshooting/index.html +1 -1
  156. package/site/reference.meta +1 -1
  157. package/site/search/search_index.json +1 -1
  158. package/site/sitemap.xml +46 -38
  159. package/site/sitemap.xml.gz +0 -0
  160. package/site/stylesheets/extra.css.meta +1 -1
  161. package/site/stylesheets.meta +1 -1
@@ -0,0 +1,188 @@
1
+ /**
2
+ * @fileoverview Tests for validate-workflows.js logic.
3
+ *
4
+ * These tests validate the core detection logic for problematic
5
+ * git add --renormalize patterns in GitHub Actions workflows.
6
+ */
7
+
8
+ "use strict";
9
+
10
+ const {
11
+ isForbiddenRenormalizePattern,
12
+ hasExistenceCheck,
13
+ } = require('../validate-workflows.js');
14
+
15
+ describe("isForbiddenRenormalizePattern", () => {
16
+ describe("should detect FORBIDDEN patterns", () => {
17
+ test("single line with multiple distinct extensions", () => {
18
+ const line =
19
+ "git add --renormalize -- '*.md' '**/*.md' '*.json' '**/*.json'";
20
+ expect(isForbiddenRenormalizePattern(line)).toBe(true);
21
+ });
22
+
23
+ test("single line with three extensions", () => {
24
+ const line =
25
+ "git add --renormalize -- '*.md' '*.json' '*.yml'";
26
+ expect(isForbiddenRenormalizePattern(line)).toBe(true);
27
+ });
28
+
29
+ test("double-quoted patterns", () => {
30
+ const line =
31
+ 'git add --renormalize -- "*.md" "**/*.md" "*.json" "**/*.json"';
32
+ expect(isForbiddenRenormalizePattern(line)).toBe(true);
33
+ });
34
+
35
+ test("mixed quote styles", () => {
36
+ const line =
37
+ "git add --renormalize -- '*.cs' \"*.md\"";
38
+ expect(isForbiddenRenormalizePattern(line)).toBe(true);
39
+ });
40
+
41
+ test("indented in workflow file", () => {
42
+ const line =
43
+ " git add --renormalize -- '*.md' '*.json' '*.yml'";
44
+ expect(isForbiddenRenormalizePattern(line)).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe("should ALLOW safe patterns", () => {
49
+ test("single extension with recursive pattern", () => {
50
+ const line = "git add --renormalize -- '*.md' '**/*.md'";
51
+ expect(isForbiddenRenormalizePattern(line)).toBe(false);
52
+ });
53
+
54
+ test("variable-based pattern in loop", () => {
55
+ const line = 'git add --renormalize -- "*.$ext" "**/*.$ext"';
56
+ expect(isForbiddenRenormalizePattern(line)).toBe(false);
57
+ });
58
+
59
+ test("variable-based pattern with braces", () => {
60
+ const line = 'git add --renormalize -- "*.${ext}" "**/*.${ext}"';
61
+ expect(isForbiddenRenormalizePattern(line)).toBe(false);
62
+ });
63
+
64
+ test("single specific file", () => {
65
+ const line =
66
+ "git add --renormalize -- '.config/dotnet-tools.json'";
67
+ expect(isForbiddenRenormalizePattern(line)).toBe(false);
68
+ });
69
+
70
+ test("line without git add", () => {
71
+ const line = "echo 'renormalize'";
72
+ expect(isForbiddenRenormalizePattern(line)).toBe(false);
73
+ });
74
+
75
+ test("line without renormalize", () => {
76
+ const line = "git add -- '*.md' '*.json'";
77
+ expect(isForbiddenRenormalizePattern(line)).toBe(false);
78
+ });
79
+
80
+ test("add_options: --renormalize (YAML key)", () => {
81
+ const line = "add_options: --renormalize";
82
+ expect(isForbiddenRenormalizePattern(line)).toBe(false);
83
+ });
84
+
85
+ test("comment describing renormalize", () => {
86
+ const line =
87
+ "# Use --renormalize to ensure line endings";
88
+ expect(isForbiddenRenormalizePattern(line)).toBe(false);
89
+ });
90
+ });
91
+ });
92
+
93
+ describe("hasExistenceCheck", () => {
94
+ describe("should detect proper guards", () => {
95
+ test("if statement with git ls-files and grep", () => {
96
+ const lines = [
97
+ "for ext in cs md json; do",
98
+ ' if git ls-files "*.$ext" "**/*.$ext" | grep -q .; then',
99
+ ' git add --renormalize -- "*.$ext" "**/*.$ext"',
100
+ " fi",
101
+ "done",
102
+ ];
103
+ expect(hasExistenceCheck(lines, 2)).toBe(true);
104
+ });
105
+
106
+ test("for loop with existence check", () => {
107
+ const lines = [
108
+ "for ext in cs md json asmdef yml yaml; do",
109
+ ' if git ls-files "*.$ext" "**/*.$ext" | grep -q .; then',
110
+ ' git add --renormalize -- "*.$ext" "**/*.$ext"',
111
+ " fi",
112
+ "done",
113
+ ];
114
+ expect(hasExistenceCheck(lines, 2)).toBe(true);
115
+ });
116
+ });
117
+
118
+ describe("should detect missing guards", () => {
119
+ test("direct command without check", () => {
120
+ const lines = [
121
+ "- name: Renormalize line endings",
122
+ " run: |",
123
+ " git add --renormalize -- '*.md' '**/*.md'",
124
+ ];
125
+ expect(hasExistenceCheck(lines, 2)).toBe(false);
126
+ });
127
+
128
+ test("guard too far away", () => {
129
+ const lines = [
130
+ 'if git ls-files "*.md" | grep -q .; then',
131
+ " echo 'files exist'",
132
+ "fi",
133
+ "",
134
+ "",
135
+ "",
136
+ "",
137
+ "git add --renormalize -- '*.md' '**/*.md'",
138
+ ];
139
+ // Index 7, lookback 5 won't reach index 0
140
+ expect(hasExistenceCheck(lines, 7)).toBe(false);
141
+ });
142
+ });
143
+ });
144
+
145
+ describe("Real workflow patterns", () => {
146
+ describe("should correctly handle actual workflow content", () => {
147
+ test("correct per-extension loop pattern", () => {
148
+ const workflowContent = `
149
+ - name: Renormalize line endings
150
+ shell: bash
151
+ run: |
152
+ # Renormalize each extension separately to avoid "pathspec did not match" failures
153
+ for ext in cs md json asmdef yml yaml; do
154
+ if git ls-files "*.$ext" "**/*.$ext" | grep -q .; then
155
+ git add --renormalize -- "*.$ext" "**/*.$ext"
156
+ fi
157
+ done
158
+ `;
159
+ const lines = workflowContent.split("\n");
160
+ const renormalizeLine = lines.findIndex((l) =>
161
+ l.includes("git add --renormalize")
162
+ );
163
+
164
+ // Should NOT be forbidden (uses variable)
165
+ expect(isForbiddenRenormalizePattern(lines[renormalizeLine])).toBe(
166
+ false
167
+ );
168
+ // Should have existence check
169
+ expect(hasExistenceCheck(lines, renormalizeLine)).toBe(true);
170
+ });
171
+
172
+ test("problematic single-line pattern", () => {
173
+ const workflowContent = `
174
+ - name: Renormalize line endings
175
+ run: git add --renormalize -- '*.md' '*.markdown' '*.json'
176
+ `;
177
+ const lines = workflowContent.split("\n");
178
+ const renormalizeLine = lines.findIndex((l) =>
179
+ l.includes("git add --renormalize")
180
+ );
181
+
182
+ // Should BE forbidden (multiple extensions on single line)
183
+ expect(isForbiddenRenormalizePattern(lines[renormalizeLine])).toBe(
184
+ true
185
+ );
186
+ });
187
+ });
188
+ });
@@ -0,0 +1,7 @@
1
+ fileFormatVersion: 2
2
+ guid: cfde2601b3671e813b015ff9eddfb824
3
+ DefaultImporter:
4
+ externalObjects: {}
5
+ userData:
6
+ assetBundleName:
7
+ assetBundleVariant:
@@ -33,6 +33,79 @@ const EXCLUDED_DIRS = ["templates"];
33
33
  const REPO_ROOT = path.join(__dirname, "..");
34
34
  const PRETTIER_VERSION = "3.8.1";
35
35
 
36
+ /**
37
+ * Brand name capitalization mapping.
38
+ * Maps lowercase words to their properly capitalized forms.
39
+ */
40
+ const BRAND_NAMES = {
41
+ github: "GitHub",
42
+ javascript: "JavaScript",
43
+ typescript: "TypeScript",
44
+ nodejs: "Node.js",
45
+ csharp: "C#",
46
+ dotnet: ".NET",
47
+ nuget: "NuGet",
48
+ npm: "npm",
49
+ api: "API",
50
+ apis: "APIs",
51
+ cli: "CLI",
52
+ json: "JSON",
53
+ yaml: "YAML",
54
+ xml: "XML",
55
+ html: "HTML",
56
+ css: "CSS",
57
+ sql: "SQL",
58
+ url: "URL",
59
+ urls: "URLs",
60
+ uri: "URI",
61
+ uris: "URIs",
62
+ http: "HTTP",
63
+ https: "HTTPS",
64
+ rest: "REST",
65
+ graphql: "GraphQL",
66
+ oauth: "OAuth",
67
+ jwt: "JWT",
68
+ sdk: "SDK",
69
+ ide: "IDE",
70
+ vscode: "VS Code",
71
+ visualstudio: "Visual Studio",
72
+ macos: "macOS",
73
+ ios: "iOS",
74
+ webgl: "WebGL",
75
+ opengl: "OpenGL",
76
+ directx: "DirectX",
77
+ llm: "LLM",
78
+ ai: "AI",
79
+ ml: "ML",
80
+ ci: "CI",
81
+ cd: "CD",
82
+ };
83
+
84
+ /**
85
+ * Apply proper brand name capitalization to a word.
86
+ * Returns the properly capitalized form if it's a known brand,
87
+ * otherwise returns the word with standard title case.
88
+ */
89
+ function applyBrandCapitalization(word) {
90
+ const lowerWord = word.toLowerCase();
91
+ if (BRAND_NAMES[lowerWord]) {
92
+ return BRAND_NAMES[lowerWord];
93
+ }
94
+ // Standard title case: capitalize first letter
95
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
96
+ }
97
+
98
+ /**
99
+ * Convert a category slug (e.g., "github-actions") to a properly
100
+ * capitalized title (e.g., "GitHub Actions").
101
+ */
102
+ function categoryToTitle(category) {
103
+ return category
104
+ .split("-")
105
+ .map((word) => applyBrandCapitalization(word))
106
+ .join(" ");
107
+ }
108
+
36
109
  function normalizeToCrlf(text) {
37
110
  let normalized = text.replace(/\r\n/g, "\n");
38
111
  normalized = normalized.replace(/\r/g, "\n");
@@ -369,7 +442,7 @@ function generateIndex(skills) {
369
442
  `;
370
443
 
371
444
  for (const cat of sortedCategories) {
372
- const catTitle = cat.charAt(0).toUpperCase() + cat.slice(1).replace(/-/g, " ");
445
+ const catTitle = categoryToTitle(cat);
373
446
  content += `- [${catTitle}](#${cat}) (${byCategory[cat].length})\n`;
374
447
  }
375
448
 
@@ -380,7 +453,7 @@ function generateIndex(skills) {
380
453
 
381
454
  // Category sections
382
455
  for (const cat of sortedCategories) {
383
- const catTitle = cat.charAt(0).toUpperCase() + cat.slice(1).replace(/-/g, " ");
456
+ const catTitle = categoryToTitle(cat);
384
457
  content += `## ${catTitle}\n\n`;
385
458
 
386
459
  content += `| Skill | Lines | Complexity | Status | Performance | Tags |\n`;
@@ -509,4 +582,16 @@ function main() {
509
582
  return 0;
510
583
  }
511
584
 
512
- process.exit(main());
585
+ // Export for testing when required as a module
586
+ if (typeof module !== 'undefined' && module.exports) {
587
+ module.exports = {
588
+ applyBrandCapitalization,
589
+ categoryToTitle,
590
+ BRAND_NAMES,
591
+ };
592
+ }
593
+
594
+ // Only run main when executed directly (not when required as a module)
595
+ if (require.main === module) {
596
+ process.exit(main());
597
+ }
@@ -35,6 +35,7 @@ const EXCLUDED_DIRS = ['templates'];
35
35
  // Files excluded from "short file" informational messages
36
36
  const SHORT_FILE_EXCLUDES = ['context.md'];
37
37
 
38
+ // Required fields that must be present in all skill files
38
39
  const REQUIRED_FIELDS = ['title', 'id', 'category', 'version', 'created', 'updated', 'status'];
39
40
 
40
41
  // File size limits (in lines)
@@ -54,6 +55,7 @@ const VALID_CATEGORIES = [
54
55
  'code-generation',
55
56
  'documentation',
56
57
  'scripting',
58
+ 'github-actions',
57
59
  ];
58
60
 
59
61
  const VALID_COMPLEXITY_LEVELS = ['basic', 'intermediate', 'advanced', 'expert'];
@@ -73,6 +75,127 @@ class ValidationError {
73
75
  }
74
76
  }
75
77
 
78
+ /**
79
+ * Validates a single required field of a frontmatter object.
80
+ *
81
+ * @param {Object} frontmatter - The parsed frontmatter object
82
+ * @param {string} field - The field name to validate
83
+ * @param {string} relativePath - The relative path for error reporting
84
+ * @returns {ValidationError[]} Array of validation errors
85
+ */
86
+ function validateRequiredField(frontmatter, field, relativePath) {
87
+ const errors = [];
88
+
89
+ if (frontmatter[field] === undefined || frontmatter[field] === null) {
90
+ errors.push(new ValidationError(relativePath, field, `Required field '${field}' is missing`));
91
+ } else if (frontmatter[field] === '') {
92
+ errors.push(new ValidationError(relativePath, field, `Required field '${field}' is empty`));
93
+ }
94
+
95
+ return errors;
96
+ }
97
+
98
+ /**
99
+ * Validates the tags field of a frontmatter object.
100
+ *
101
+ * @param {Object} frontmatter - The parsed frontmatter object
102
+ * @param {string} relativePath - The relative path for error reporting
103
+ * @returns {ValidationError[]} Array of validation warnings
104
+ */
105
+ function validateTags(frontmatter, relativePath) {
106
+ const warnings = [];
107
+
108
+ if (frontmatter.tags === undefined || frontmatter.tags === null) {
109
+ warnings.push(
110
+ new ValidationError(
111
+ relativePath,
112
+ 'tags',
113
+ `Missing 'tags' array - will show empty Tags column in skills index`
114
+ )
115
+ );
116
+ } else if (!Array.isArray(frontmatter.tags)) {
117
+ warnings.push(
118
+ new ValidationError(
119
+ relativePath,
120
+ 'tags',
121
+ `'tags' must be an array, got ${typeof frontmatter.tags} - will show empty Tags column in skills index`
122
+ )
123
+ );
124
+ } else if (frontmatter.tags.length === 0) {
125
+ warnings.push(
126
+ new ValidationError(
127
+ relativePath,
128
+ 'tags',
129
+ `Empty 'tags' array - will show empty Tags column in skills index`
130
+ )
131
+ );
132
+ }
133
+
134
+ return warnings;
135
+ }
136
+
137
+ /**
138
+ * Validates the complexity.level field of a frontmatter object.
139
+ *
140
+ * @param {Object} frontmatter - The parsed frontmatter object
141
+ * @param {string} relativePath - The relative path for error reporting
142
+ * @returns {ValidationError[]} Array of validation warnings
143
+ */
144
+ function validateComplexityLevel(frontmatter, relativePath) {
145
+ const warnings = [];
146
+
147
+ if (frontmatter.complexity == null || frontmatter.complexity.level == null) {
148
+ warnings.push(
149
+ new ValidationError(
150
+ relativePath,
151
+ 'complexity.level',
152
+ `Missing 'complexity.level' - will show '?' in Complexity column of skills index`
153
+ )
154
+ );
155
+ } else if (frontmatter.complexity.level === '') {
156
+ warnings.push(
157
+ new ValidationError(
158
+ relativePath,
159
+ 'complexity.level',
160
+ `Empty 'complexity.level' - will show '?' in Complexity column of skills index`
161
+ )
162
+ );
163
+ }
164
+
165
+ return warnings;
166
+ }
167
+
168
+ /**
169
+ * Validates the impact.performance.rating field of a frontmatter object.
170
+ *
171
+ * @param {Object} frontmatter - The parsed frontmatter object
172
+ * @param {string} relativePath - The relative path for error reporting
173
+ * @returns {ValidationError[]} Array of validation warnings
174
+ */
175
+ function validatePerformanceRating(frontmatter, relativePath) {
176
+ const warnings = [];
177
+
178
+ if (frontmatter.impact == null || frontmatter.impact.performance == null || frontmatter.impact.performance.rating == null) {
179
+ warnings.push(
180
+ new ValidationError(
181
+ relativePath,
182
+ 'impact.performance.rating',
183
+ `Missing 'impact.performance.rating' - will show '?' in Performance column of skills index`
184
+ )
185
+ );
186
+ } else if (frontmatter.impact.performance.rating === '') {
187
+ warnings.push(
188
+ new ValidationError(
189
+ relativePath,
190
+ 'impact.performance.rating',
191
+ `Empty 'impact.performance.rating' - will show '?' in Performance column of skills index`
192
+ )
193
+ );
194
+ }
195
+
196
+ return warnings;
197
+ }
198
+
76
199
  /**
77
200
  * Parse YAML frontmatter from markdown file content.
78
201
  * Uses a stack-based approach to handle arbitrary nesting depth.
@@ -216,6 +339,33 @@ function findSkillFiles(dir) {
216
339
  return skills;
217
340
  }
218
341
 
342
+ /**
343
+ * Validates that impact is present and is an object (not a primitive or array).
344
+ *
345
+ * @param {Object} frontmatter - The parsed frontmatter object
346
+ * @returns {boolean} True if impact is a valid object to iterate over
347
+ */
348
+ function isValidImpactObject(frontmatter) {
349
+ return frontmatter.impact != null && typeof frontmatter.impact === 'object' && !Array.isArray(frontmatter.impact);
350
+ }
351
+
352
+ /**
353
+ * Validates all required fields of a frontmatter object.
354
+ *
355
+ * @param {Object} frontmatter - The parsed frontmatter object
356
+ * @param {string} relativePath - The relative path for error reporting
357
+ * @returns {ValidationError[]} Array of validation errors
358
+ */
359
+ function validateRequiredFields(frontmatter, relativePath) {
360
+ const errors = [];
361
+
362
+ for (const field of REQUIRED_FIELDS) {
363
+ errors.push(...validateRequiredField(frontmatter, field, relativePath));
364
+ }
365
+
366
+ return errors;
367
+ }
368
+
219
369
  /**
220
370
  * Validate a single skill file.
221
371
  * Returns validation results with errors, warnings, and line count.
@@ -269,14 +419,10 @@ function validateSkill(skillFile) {
269
419
  }
270
420
 
271
421
  // Check required fields
272
- for (const field of REQUIRED_FIELDS) {
273
- if (!frontmatter[field]) {
274
- errors.push(new ValidationError(skillFile.relativePath, field, `Required field '${field}' is missing`));
275
- }
276
- }
422
+ errors.push(...validateRequiredFields(frontmatter, skillFile.relativePath));
277
423
 
278
424
  // Validate id matches filename
279
- if (frontmatter.id && frontmatter.id !== skillFile.expectedId) {
425
+ if (frontmatter.id != null && frontmatter.id !== '' && frontmatter.id !== skillFile.expectedId) {
280
426
  errors.push(
281
427
  new ValidationError(
282
428
  skillFile.relativePath,
@@ -287,7 +433,7 @@ function validateSkill(skillFile) {
287
433
  }
288
434
 
289
435
  // Validate category matches folder
290
- if (frontmatter.category && frontmatter.category !== skillFile.category) {
436
+ if (frontmatter.category != null && frontmatter.category !== '' && frontmatter.category !== skillFile.category) {
291
437
  errors.push(
292
438
  new ValidationError(
293
439
  skillFile.relativePath,
@@ -298,7 +444,7 @@ function validateSkill(skillFile) {
298
444
  }
299
445
 
300
446
  // Validate category is known
301
- if (frontmatter.category && !VALID_CATEGORIES.includes(frontmatter.category)) {
447
+ if (frontmatter.category != null && frontmatter.category !== '' && !VALID_CATEGORIES.includes(frontmatter.category)) {
302
448
  warnings.push(
303
449
  new ValidationError(
304
450
  skillFile.relativePath,
@@ -309,7 +455,7 @@ function validateSkill(skillFile) {
309
455
  }
310
456
 
311
457
  // Validate status
312
- if (frontmatter.status && !VALID_STATUSES.includes(frontmatter.status)) {
458
+ if (frontmatter.status != null && frontmatter.status !== '' && !VALID_STATUSES.includes(frontmatter.status)) {
313
459
  errors.push(
314
460
  new ValidationError(
315
461
  skillFile.relativePath,
@@ -321,8 +467,9 @@ function validateSkill(skillFile) {
321
467
 
322
468
  // Validate complexity level
323
469
  if (
324
- frontmatter.complexity &&
325
- frontmatter.complexity.level &&
470
+ frontmatter.complexity != null &&
471
+ frontmatter.complexity.level != null &&
472
+ frontmatter.complexity.level !== '' &&
326
473
  !VALID_COMPLEXITY_LEVELS.includes(frontmatter.complexity.level)
327
474
  ) {
328
475
  warnings.push(
@@ -335,7 +482,7 @@ function validateSkill(skillFile) {
335
482
  }
336
483
 
337
484
  // Validate impact ratings
338
- if (frontmatter.impact) {
485
+ if (isValidImpactObject(frontmatter)) {
339
486
  // Warn about unknown impact types
340
487
  for (const impactType of Object.keys(frontmatter.impact)) {
341
488
  if (!VALID_IMPACT_TYPES.includes(impactType)) {
@@ -351,8 +498,9 @@ function validateSkill(skillFile) {
351
498
  // Validate ratings for known impact types
352
499
  for (const impactType of VALID_IMPACT_TYPES) {
353
500
  if (
354
- frontmatter.impact[impactType] &&
355
- frontmatter.impact[impactType].rating &&
501
+ frontmatter.impact[impactType] != null &&
502
+ frontmatter.impact[impactType].rating != null &&
503
+ frontmatter.impact[impactType].rating !== '' &&
356
504
  !VALID_IMPACT_RATINGS.includes(frontmatter.impact[impactType].rating)
357
505
  ) {
358
506
  warnings.push(
@@ -367,29 +515,43 @@ function validateSkill(skillFile) {
367
515
  }
368
516
 
369
517
  // Validate version format (semver-like)
370
- if (frontmatter.version && !frontmatter.version.match(/^\d+\.\d+\.\d+$/)) {
371
- warnings.push(
372
- new ValidationError(
373
- skillFile.relativePath,
374
- 'version',
375
- `Version '${frontmatter.version}' should be in semver format (e.g., 1.0.0)`
376
- )
377
- );
378
- }
379
-
380
- // Validate date format
381
- for (const dateField of ['created', 'updated']) {
382
- if (frontmatter[dateField] && !frontmatter[dateField].match(/^\d{4}-\d{2}-\d{2}$/)) {
518
+ if (frontmatter.version != null && frontmatter.version !== '') {
519
+ // Coerce to string to handle numeric or other non-string types
520
+ const versionStr = String(frontmatter.version);
521
+ if (!versionStr.match(/^\d+\.\d+\.\d+$/)) {
383
522
  warnings.push(
384
523
  new ValidationError(
385
524
  skillFile.relativePath,
386
- dateField,
387
- `Date '${frontmatter[dateField]}' should be in ISO format (YYYY-MM-DD)`
525
+ 'version',
526
+ `Version '${versionStr}' should be in semver format (e.g., 1.0.0)`
388
527
  )
389
528
  );
390
529
  }
391
530
  }
392
531
 
532
+ // Warn about missing optional fields that affect skills index display
533
+ // These are not required but cause '?' placeholders in the generated index
534
+ warnings.push(...validateComplexityLevel(frontmatter, skillFile.relativePath));
535
+ warnings.push(...validatePerformanceRating(frontmatter, skillFile.relativePath));
536
+ warnings.push(...validateTags(frontmatter, skillFile.relativePath));
537
+
538
+ // Validate date format
539
+ for (const dateField of ['created', 'updated']) {
540
+ if (frontmatter[dateField] != null && frontmatter[dateField] !== '') {
541
+ // Coerce to string to handle numeric or other non-string types
542
+ const dateStr = String(frontmatter[dateField]);
543
+ if (!dateStr.match(/^\d{4}-\d{2}-\d{2}$/)) {
544
+ warnings.push(
545
+ new ValidationError(
546
+ skillFile.relativePath,
547
+ dateField,
548
+ `Date '${dateStr}' should be in ISO format (YYYY-MM-DD)`
549
+ )
550
+ );
551
+ }
552
+ }
553
+ }
554
+
393
555
  // Check for required sections in body
394
556
  const requiredSections = ['## Overview', '## Solution'];
395
557
  for (const section of requiredSections) {
@@ -554,4 +716,42 @@ function main() {
554
716
  return 0;
555
717
  }
556
718
 
557
- process.exit(main());
719
+ /**
720
+ * @module validate-skills
721
+ * @description Validates skill files in .llm/skills/ for frontmatter correctness, naming conventions,
722
+ * size limits, and cross-references. Used by pre-commit hooks and CI pipelines.
723
+ *
724
+ * @exports {Function} validateSkill - Validates a single skill file, returning errors and warnings
725
+ * @exports {Function} parseFrontmatter - Extracts YAML frontmatter from markdown content
726
+ * @exports {Function} validateRequiredField - Validates a single required frontmatter field
727
+ * @exports {Function} validateRequiredFields - Validates all required frontmatter fields
728
+ * @exports {Function} validateTags - Validates the tags array in frontmatter
729
+ * @exports {Function} validateComplexityLevel - Validates the complexity.level field
730
+ * @exports {Function} validatePerformanceRating - Validates presence of impact.performance.rating field
731
+ * @exports {Function} isValidImpactObject - Checks if impact field is a non-null object (excludes arrays)
732
+ * @exports {Class} ValidationError - Error class with file, field, and message properties
733
+ * @exports {Array<string>} REQUIRED_FIELDS - List of required frontmatter field names
734
+ * @exports {Array<string>} VALID_COMPLEXITY_LEVELS - Valid values: 'basic', 'intermediate', 'advanced', 'expert'
735
+ * @exports {Array<string>} VALID_IMPACT_RATINGS - Valid rating values: 'none', 'low', 'medium', 'high', 'critical'
736
+ */
737
+ if (typeof module !== 'undefined' && module.exports) {
738
+ module.exports = {
739
+ validateSkill,
740
+ parseFrontmatter,
741
+ ValidationError,
742
+ REQUIRED_FIELDS,
743
+ VALID_COMPLEXITY_LEVELS,
744
+ VALID_IMPACT_RATINGS,
745
+ validateRequiredField,
746
+ validateRequiredFields,
747
+ validateTags,
748
+ validateComplexityLevel,
749
+ validatePerformanceRating,
750
+ isValidImpactObject,
751
+ };
752
+ }
753
+
754
+ // Only run main when executed directly (not when required as a module)
755
+ if (require.main === module) {
756
+ process.exit(main());
757
+ }