@savvy-web/changesets 0.2.1 → 0.4.0

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.
package/README.md CHANGED
@@ -2,24 +2,24 @@
2
2
 
3
3
  [![npm version][npm-badge]][npm-url]
4
4
  [![License: MIT][license-badge]][license-url]
5
+ [![Node.js >= 24][node-badge]][node-url]
5
6
 
6
7
  Custom changelog formatter and markdown processing pipeline for the Silk Suite. Replaces the default `@changesets/cli/changelog` formatter with a three-layer architecture that validates changeset files, formats structured changelog entries, and post-processes the generated CHANGELOG.md.
7
8
 
8
9
  ## Features
9
10
 
10
- - **Section-aware changesets** -- Use h2 headings in changeset files to categorize changes (Features, Bug Fixes, Breaking Changes, etc.)
11
+ - **Section-aware changesets** -- Categorize changes with h2 headings (Features, Bug Fixes, Breaking Changes, etc.)
11
12
  - **Three-layer pipeline** -- Pre-validation (remark-lint), changelog formatting (Changesets API), and post-processing (remark-transform)
12
- - **13 section categories** -- Consistent categorization with priority-based ordering across all layers
13
- - **CLI tooling** -- `savvy-changesets` binary with init, lint, transform, check, and version subcommands for CI and local use
13
+ - **CLI tooling** -- `savvy-changesets` binary with init, lint, check, transform, and version subcommands
14
14
  - **GitHub integration** -- Automatic PR links, commit references, and contributor attribution
15
- - **Version file syncing** -- Bump version fields in additional JSON files (beyond `package.json`) using glob patterns and JSONPath expressions
16
- - **Remark plugins** -- Lint rules and transform plugins via `@savvy-web/changesets/remark`
17
- - **markdownlint rules** -- Custom rules compatible with [markdownlint-cli2](https://www.npmjs.com/package/markdownlint-cli2) and the VS Code extension via `@savvy-web/changesets/markdownlint`
15
+ - **Version file syncing** -- Bump version fields in additional JSON files using glob patterns and JSONPath expressions
16
+ - **Editor support** -- markdownlint rules for real-time validation in VS Code and CI
17
+ - **AI-agent-friendly errors** -- All lint and validation errors include inline fix instructions and documentation links, so AI agents can resolve issues without examining source code
18
18
 
19
19
  ## Installation
20
20
 
21
21
  ```bash
22
- pnpm add @savvy-web/changesets
22
+ npm install @savvy-web/changesets -D
23
23
  ```
24
24
 
25
25
  ## Quick Start
@@ -30,7 +30,7 @@ Bootstrap your repository:
30
30
  savvy-changesets init
31
31
  ```
32
32
 
33
- This creates `.changeset/config.json` with auto-detected GitHub repo settings. Or configure manually:
33
+ This creates `.changeset/config.json` with auto-detected GitHub repo settings and configures markdownlint rules. Or configure manually:
34
34
 
35
35
  ```json
36
36
  {
@@ -41,7 +41,7 @@ This creates `.changeset/config.json` with auto-detected GitHub repo settings. O
41
41
  }
42
42
  ```
43
43
 
44
- Write [section-aware changeset files](docs/section-aware-pipeline.md):
44
+ Write section-aware changeset files:
45
45
 
46
46
  ```markdown
47
47
  ---
@@ -58,58 +58,14 @@ Added a new authentication system with OAuth2 support.
58
58
  - Updated integration test fixtures
59
59
  ```
60
60
 
61
- ## Version File Syncing
62
-
63
- If your project has JSON files beyond `package.json` that contain version fields (e.g., `plugin.json`, `marketplace.json`), add `versionFiles` to your changelog options to keep them in sync during `changeset version`:
64
-
65
- ```json
66
- {
67
- "changelog": ["@savvy-web/changesets/changelog", {
68
- "repo": "owner/repo",
69
- "versionFiles": [
70
- { "glob": "plugin.json" },
71
- { "glob": ".claude-plugin/marketplace.json", "paths": ["$.metadata.version", "$.plugins[*].version"] }
72
- ]
73
- }]
74
- }
75
- ```
76
-
77
- When `paths` is omitted it defaults to `["$.version"]`. In monorepos, each matched file inherits the version from its nearest workspace package. See [Configuration -- versionFiles](./docs/configuration.md#versionfiles-optional) for full details including supported JSONPath syntax.
78
-
79
- ## markdownlint Integration
80
-
81
- Register the custom rules in your base config (e.g., `lib/configs/.markdownlint-cli2.jsonc`):
82
-
83
- ```jsonc
84
- {
85
- "customRules": [
86
- "@savvy-web/changesets/markdownlint"
87
- ],
88
- "config": {
89
- "changeset-heading-hierarchy": false,
90
- "changeset-required-sections": false,
91
- "changeset-content-structure": false
92
- }
93
- }
94
- ```
95
-
96
- Then enable the rules only for changeset files by creating `.changeset/.markdownlint.json`:
97
-
98
- ```json
99
- {
100
- "extends": "../lib/configs/.markdownlint-cli2.jsonc",
101
- "default": false,
102
- "changeset-heading-hierarchy": true,
103
- "changeset-required-sections": true,
104
- "changeset-content-structure": true,
105
- "MD041": false
106
- }
107
- ```
108
-
109
61
  ## Documentation
110
62
 
111
- - [Section-Aware Pipeline](./docs/section-aware-pipeline.md) -- End-to-end walkthrough of how section-aware changesets flow through the three-layer pipeline
112
- - [Full documentation](./docs/) -- CLI usage, API reference, configuration, and architecture
63
+ - [Section-Aware Pipeline](./docs/section-aware-pipeline.md) -- End-to-end walkthrough of how section-aware changesets flow through the pipeline
64
+ - [Configuration](./docs/configuration.md) -- Options, version files, markdownlint integration, CI scripts
65
+ - [CLI Reference](./docs/cli.md) -- All commands and options
66
+ - [API Reference](./docs/api.md) -- Classes, types, Effect services, remark plugins
67
+ - [Architecture](./docs/architecture.md) -- Three-layer pipeline design and export map
68
+ - [Markdownlint Rule Docs](./docs/rules/) -- Per-rule documentation with examples, fix instructions, and rationale
113
69
 
114
70
  ## License
115
71
 
@@ -119,3 +75,5 @@ MIT
119
75
  [npm-url]: https://www.npmjs.com/package/@savvy-web/changesets
120
76
  [license-badge]: https://img.shields.io/badge/License-MIT-yellow.svg
121
77
  [license-url]: https://opensource.org/licenses/MIT
78
+ [node-badge]: https://img.shields.io/badge/node-%3E%3D24-brightgreen
79
+ [node-url]: https://nodejs.org/
package/cjs/changelog.cjs CHANGED
@@ -181,7 +181,7 @@ var __webpack_modules__ = {
181
181
  }
182
182
  }
183
183
  const UsernameSchema = effect__rspack_import_0.Schema.String.pipe(effect__rspack_import_0.Schema.pattern(/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/, {
184
- message: ()=>"Invalid GitHub username format"
184
+ message: ()=>'Invalid GitHub username format. Usernames must contain only alphanumeric characters and hyphens, and cannot start or end with a hyphen. Example: "octocat" or "my-user-123"'
185
185
  }));
186
186
  const IssueNumberSchema = _primitives_js__rspack_import_1.e.annotations({
187
187
  title: "IssueNumber",
@@ -192,7 +192,7 @@ var __webpack_modules__ = {
192
192
  const match = _utils_markdown_link_js__rspack_import_2.o.exec(value);
193
193
  return match?.[2] ? isValidUrl(match[2]) : false;
194
194
  }, {
195
- message: ()=>"Invalid URL or markdown link format"
195
+ message: ()=>'Value must be a valid URL or a markdown link. Expected a plain URL (e.g., "https://github.com/owner/repo/pull/42") or a markdown link (e.g., "[#42](https://github.com/owner/repo/pull/42)")'
196
196
  }));
197
197
  const GitHubInfoSchema = effect__rspack_import_0.Schema.Struct({
198
198
  user: effect__rspack_import_0.Schema.optional(UsernameSchema),
package/cjs/index.cjs CHANGED
@@ -490,7 +490,7 @@ var __webpack_modules__ = {
490
490
  }
491
491
  }
492
492
  const UsernameSchema = effect__rspack_import_0.Schema.String.pipe(effect__rspack_import_0.Schema.pattern(/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/, {
493
- message: ()=>"Invalid GitHub username format"
493
+ message: ()=>'Invalid GitHub username format. Usernames must contain only alphanumeric characters and hyphens, and cannot start or end with a hyphen. Example: "octocat" or "my-user-123"'
494
494
  }));
495
495
  const IssueNumberSchema = _primitives_js__rspack_import_1.e.annotations({
496
496
  title: "IssueNumber",
@@ -501,7 +501,7 @@ var __webpack_modules__ = {
501
501
  const match = _utils_markdown_link_js__rspack_import_2.o.exec(value);
502
502
  return match?.[2] ? isValidUrl(match[2]) : false;
503
503
  }, {
504
- message: ()=>"Invalid URL or markdown link format"
504
+ message: ()=>'Value must be a valid URL or a markdown link. Expected a plain URL (e.g., "https://github.com/owner/repo/pull/42") or a markdown link (e.g., "[#42](https://github.com/owner/repo/pull/42)")'
505
505
  }));
506
506
  const GitHubInfoSchema = effect__rspack_import_0.Schema.Struct({
507
507
  user: effect__rspack_import_0.Schema.optional(UsernameSchema),
@@ -830,25 +830,32 @@ var __webpack_exports__ = {};
830
830
  var external_mdast_util_to_string_ = __webpack_require__("mdast-util-to-string");
831
831
  const external_unified_lint_rule_namespaceObject = require("unified-lint-rule");
832
832
  const external_unist_util_visit_namespaceObject = require("unist-util-visit");
833
+ const DOCS_BASE = "https://github.com/savvy-web/changesets/blob/main/docs/rules";
834
+ const RULE_DOCS = {
835
+ CSH001: `${DOCS_BASE}/CSH001.md`,
836
+ CSH002: `${DOCS_BASE}/CSH002.md`,
837
+ CSH003: `${DOCS_BASE}/CSH003.md`,
838
+ CSH004: `${DOCS_BASE}/CSH004.md`
839
+ };
833
840
  const ContentStructureRule = (0, external_unified_lint_rule_namespaceObject.lintRule)("remark-lint:changeset-content-structure", (tree, file)=>{
834
841
  (0, external_unist_util_visit_namespaceObject.visit)(tree, "heading", (node, index, parent)=>{
835
842
  if (2 !== node.depth || null == parent || null == index) return;
836
843
  const next = parent.children[index + 1];
837
- if (!next || "heading" === next.type && 2 === next.depth) file.message("Empty section: heading has no content before the next section or end of file", node);
844
+ if (!next || "heading" === next.type && 2 === next.depth) file.message(`Empty section: heading has no content before the next section or end of file. Add a list of changes (e.g., "- Added feature X") under this heading, or remove the empty heading. See: ${RULE_DOCS.CSH003}`, node);
838
845
  });
839
846
  (0, external_unist_util_visit_namespaceObject.visit)(tree, "code", (node)=>{
840
- if (!node.lang) file.message("Code block is missing a language identifier", node);
847
+ if (!node.lang) file.message(`Code block is missing a language identifier. Add a language after the opening fence (e.g., \`\`\`ts, \`\`\`json, \`\`\`bash). See: ${RULE_DOCS.CSH003}`, node);
841
848
  });
842
849
  (0, external_unist_util_visit_namespaceObject.visit)(tree, "listItem", (node)=>{
843
850
  const text = (0, external_mdast_util_to_string_.toString)(node).trim();
844
- if (!text) file.message("Empty list item", node);
851
+ if (!text) file.message(`Empty list item. Each list item must contain descriptive text (e.g., "- Fixed login timeout issue"). See: ${RULE_DOCS.CSH003}`, node);
845
852
  });
846
853
  });
847
854
  const HeadingHierarchyRule = (0, external_unified_lint_rule_namespaceObject.lintRule)("remark-lint:changeset-heading-hierarchy", (tree, file)=>{
848
855
  let prevDepth = 0;
849
856
  (0, external_unist_util_visit_namespaceObject.visit)(tree, "heading", (node)=>{
850
- if (1 === node.depth) return void file.message("h1 headings are not allowed in changeset files", node);
851
- if (prevDepth > 0 && node.depth > prevDepth + 1) file.message(`Heading level skipped: expected h${prevDepth + 1} or lower, found h${node.depth}`, node);
857
+ if (1 === node.depth) return void file.message(`h1 headings are not allowed in changeset files. Use h2 (##) for top-level sections like "## Features" or "## Bug Fixes". h1 is reserved for the version title generated by the changelog formatter. See: ${RULE_DOCS.CSH001}`, node);
858
+ if (prevDepth > 0 && node.depth > prevDepth + 1) file.message(`Heading level skipped: expected h${prevDepth + 1} or lower, found h${node.depth}. Headings must increase sequentially (h2 → h3 → h4). Add the missing intermediate level or reduce this heading's depth. See: ${RULE_DOCS.CSH001}`, node);
852
859
  prevDepth = node.depth;
853
860
  });
854
861
  });
@@ -856,9 +863,22 @@ var __webpack_exports__ = {};
856
863
  (0, external_unist_util_visit_namespaceObject.visit)(tree, "heading", (node)=>{
857
864
  if (2 !== node.depth) return;
858
865
  const text = (0, external_mdast_util_to_string_.toString)(node);
859
- if (!(0, categories.Lr)(text)) file.message(`Unknown section "${text}". Valid sections: ${(0, categories.Rr)().join(", ")}`, node);
866
+ if (!(0, categories.Lr)(text)) file.message(`Unknown section "${text}". Valid h2 headings are: ${(0, categories.Rr)().join(", ")}. Heading comparison is case-insensitive. See: ${RULE_DOCS.CSH002}`, node);
860
867
  });
861
868
  });
869
+ const IGNORED_TYPES = new Set([
870
+ "html"
871
+ ]);
872
+ function isContentNode(node) {
873
+ if ("heading" === node.type) return false;
874
+ return !IGNORED_TYPES.has(node.type);
875
+ }
876
+ const UncategorizedContentRule = (0, external_unified_lint_rule_namespaceObject.lintRule)("remark-lint:changeset-uncategorized-content", (tree, file)=>{
877
+ for (const node of tree.children){
878
+ if ("heading" === node.type && 2 === node.depth) break;
879
+ if (isContentNode(node)) file.message(`Content must be placed under a category heading (## heading). Move this content under an appropriate section like "## Features" or "## Bug Fixes". If it doesn't fit an existing category, use "## Other". See: ${RULE_DOCS.CSH004}`, node);
880
+ }
881
+ });
862
882
  function stripFrontmatter(content) {
863
883
  return content.replace(/^---\n[\s\S]*?\n---\n?/, "");
864
884
  }
@@ -869,7 +889,7 @@ var __webpack_exports__ = {};
869
889
  }
870
890
  static validateContent(content, filePath = "<input>") {
871
891
  const body = stripFrontmatter(content);
872
- const processor = (0, external_unified_.unified)().use(external_remark_parse_default()).use(external_remark_stringify_default()).use(HeadingHierarchyRule).use(RequiredSectionsRule).use(ContentStructureRule);
892
+ const processor = (0, external_unified_.unified)().use(external_remark_parse_default()).use(external_remark_stringify_default()).use(HeadingHierarchyRule).use(RequiredSectionsRule).use(ContentStructureRule).use(UncategorizedContentRule);
873
893
  const file = processor.processSync(body);
874
894
  return file.messages.map((msg)=>({
875
895
  file: filePath,
@@ -1215,14 +1235,14 @@ var __webpack_exports__ = {};
1215
1235
  description: external_effect_.Schema.String
1216
1236
  });
1217
1237
  const CommitHashSchema = external_effect_.Schema.String.pipe(external_effect_.Schema.pattern(/^[a-f0-9]{7,}$/, {
1218
- message: ()=>"Commit hash must be at least 7 hexadecimal characters"
1238
+ message: ()=>'Commit hash must be 7 or more lowercase hexadecimal characters (0-9, a-f). Example: "a1b2c3d" or a full 40-character SHA like "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2". Uppercase letters are not allowed'
1219
1239
  }));
1220
1240
  const VersionTypeSchema = external_effect_.Schema.Literal("major", "minor", "patch", "none");
1221
1241
  var primitives = __webpack_require__("./src/schemas/primitives.ts");
1222
1242
  const ChangesetSummarySchema = external_effect_.Schema.String.pipe(external_effect_.Schema.minLength(1, {
1223
- message: ()=>"Changeset summary cannot be empty"
1243
+ message: ()=>'Changeset summary cannot be empty. Provide a 1-1000 character description of the change (e.g., "Fix authentication timeout in login flow")'
1224
1244
  }), external_effect_.Schema.maxLength(1000, {
1225
- message: ()=>"Changeset summary is too long"
1245
+ message: ()=>"Changeset summary exceeds the 1000 character limit. Shorten the summary to at most 1000 characters — use the changeset body for additional details"
1226
1246
  }));
1227
1247
  const ChangesetSchema = external_effect_.Schema.Struct({
1228
1248
  summary: ChangesetSummarySchema,
package/cjs/index.d.cts CHANGED
@@ -256,9 +256,9 @@ export declare interface Changeset extends Schema.Schema.Type<typeof ChangesetSc
256
256
  /**
257
257
  * Static class for linting changeset files.
258
258
  *
259
- * Runs the three remark-lint rules (heading-hierarchy, required-sections,
260
- * content-structure) against changeset markdown and returns structured
261
- * diagnostic messages.
259
+ * Runs the four remark-lint rules (heading-hierarchy, required-sections,
260
+ * content-structure, uncategorized-content) against changeset markdown
261
+ * and returns structured diagnostic messages.
262
262
  *
263
263
  * @example
264
264
  * ```typescript
@@ -26,9 +26,17 @@ __webpack_require__.r(__webpack_exports__);
26
26
  __webpack_require__.d(__webpack_exports__, {
27
27
  ContentStructureRule: ()=>ContentStructureRule,
28
28
  HeadingHierarchyRule: ()=>HeadingHierarchyRule,
29
+ UncategorizedContentRule: ()=>UncategorizedContentRule,
29
30
  default: ()=>markdownlint,
30
31
  RequiredSectionsRule: ()=>RequiredSectionsRule
31
32
  });
33
+ const DOCS_BASE = "https://github.com/savvy-web/changesets/blob/main/docs/rules";
34
+ const RULE_DOCS = {
35
+ CSH001: `${DOCS_BASE}/CSH001.md`,
36
+ CSH002: `${DOCS_BASE}/CSH002.md`,
37
+ CSH003: `${DOCS_BASE}/CSH003.md`,
38
+ CSH004: `${DOCS_BASE}/CSH004.md`
39
+ };
32
40
  function getHeadingLevel(heading) {
33
41
  const sequence = heading.children.find((c)=>"atxHeadingSequence" === c.type);
34
42
  return sequence ? sequence.text.length : 0;
@@ -63,7 +71,7 @@ const ContentStructureRule = {
63
71
  const nextIdx = i + 1 < h2Indices.length ? h2Indices[i + 1] : tokens.length;
64
72
  if (!hasContentBetween(tokens, currentIdx, nextIdx)) onError({
65
73
  lineNumber: tokens[currentIdx].startLine,
66
- detail: "Empty section: heading has no content before the next section or end of file"
74
+ detail: `Empty section: heading has no content before the next section or end of file. Add a list of changes (e.g., "- Added feature X") under this heading, or remove the empty heading. See: ${RULE_DOCS.CSH003}`
67
75
  });
68
76
  }
69
77
  for (const token of tokens){
@@ -72,7 +80,7 @@ const ContentStructureRule = {
72
80
  const hasInfo = openingFence?.children.some((c)=>"codeFencedFenceInfo" === c.type) ?? false;
73
81
  if (!hasInfo) onError({
74
82
  lineNumber: token.startLine,
75
- detail: "Code block is missing a language identifier"
83
+ detail: `Code block is missing a language identifier. Add a language after the opening fence (e.g., \`\`\`ts, \`\`\`json, \`\`\`bash). See: ${RULE_DOCS.CSH003}`
76
84
  });
77
85
  }
78
86
  for (const token of tokens){
@@ -90,7 +98,7 @@ const ContentStructureRule = {
90
98
  }
91
99
  if (!hasContent) onError({
92
100
  lineNumber: children[i].startLine,
93
- detail: "Empty list item"
101
+ detail: `Empty list item. Each list item must contain descriptive text (e.g., "- Fixed login timeout issue"). See: ${RULE_DOCS.CSH003}`
94
102
  });
95
103
  }
96
104
  }
@@ -114,13 +122,13 @@ const HeadingHierarchyRule = {
114
122
  if (1 === depth) {
115
123
  onError({
116
124
  lineNumber: token.startLine,
117
- detail: "h1 headings are not allowed in changeset files"
125
+ detail: `h1 headings are not allowed in changeset files. Use h2 (##) for top-level sections like "## Features" or "## Bug Fixes". h1 is reserved for the version title generated by the changelog formatter. See: ${RULE_DOCS.CSH001}`
118
126
  });
119
127
  continue;
120
128
  }
121
129
  if (prevDepth > 0 && depth > prevDepth + 1) onError({
122
130
  lineNumber: token.startLine,
123
- detail: `Heading level skipped: expected h${prevDepth + 1} or lower, found h${depth}`
131
+ detail: `Heading level skipped: expected h${prevDepth + 1} or lower, found h${depth}. Headings must increase sequentially (h2 → h3 → h4). Add the missing intermediate level or reduce this heading's depth. See: ${RULE_DOCS.CSH001}`
124
132
  });
125
133
  prevDepth = depth;
126
134
  }
@@ -271,25 +279,51 @@ const RequiredSectionsRule = {
271
279
  const text = getHeadingText(token);
272
280
  if (!isValidHeading(text)) onError({
273
281
  lineNumber: token.startLine,
274
- detail: `Unknown section "${text}". Valid sections: ${allHeadings().join(", ")}`
282
+ detail: `Unknown section "${text}". Valid h2 headings are: ${allHeadings().join(", ")}. Heading comparison is case-insensitive. See: ${RULE_DOCS.CSH002}`
275
283
  });
276
284
  }
277
285
  }
278
286
  };
287
+ const UncategorizedContentRule = {
288
+ names: [
289
+ "changeset-uncategorized-content",
290
+ "CSH004"
291
+ ],
292
+ description: "All content must be placed under a category heading (## heading)",
293
+ tags: [
294
+ "changeset"
295
+ ],
296
+ parser: "micromark",
297
+ function: function(params, onError) {
298
+ const tokens = params.parsers.micromark.tokens;
299
+ for (const token of tokens){
300
+ if ("atxHeading" === token.type && 2 === getHeadingLevel(token)) break;
301
+ if ("lineEnding" !== token.type && "lineEndingBlank" !== token.type && "htmlFlow" !== token.type) {
302
+ if ("atxHeading" !== token.type) onError({
303
+ lineNumber: token.startLine,
304
+ detail: `Content must be placed under a category heading (## heading). Move this content under an appropriate section like "## Features" or "## Bug Fixes". If it doesn't fit an existing category, use "## Other". See: ${RULE_DOCS.CSH004}`
305
+ });
306
+ }
307
+ }
308
+ }
309
+ };
279
310
  const SilkChangesetsRules = [
280
311
  HeadingHierarchyRule,
281
312
  RequiredSectionsRule,
282
- ContentStructureRule
313
+ ContentStructureRule,
314
+ UncategorizedContentRule
283
315
  ];
284
316
  const markdownlint = SilkChangesetsRules;
285
317
  exports.ContentStructureRule = __webpack_exports__.ContentStructureRule;
286
318
  exports.HeadingHierarchyRule = __webpack_exports__.HeadingHierarchyRule;
287
319
  exports.RequiredSectionsRule = __webpack_exports__.RequiredSectionsRule;
320
+ exports.UncategorizedContentRule = __webpack_exports__.UncategorizedContentRule;
288
321
  exports["default"] = __webpack_exports__["default"];
289
322
  for(var __rspack_i in __webpack_exports__)if (-1 === [
290
323
  "ContentStructureRule",
291
324
  "HeadingHierarchyRule",
292
325
  "RequiredSectionsRule",
326
+ "UncategorizedContentRule",
293
327
  "default"
294
328
  ].indexOf(__rspack_i)) exports[__rspack_i] = __webpack_exports__[__rspack_i];
295
329
  Object.defineProperty(exports, '__esModule', {
@@ -8,6 +8,7 @@
8
8
  * - `changeset-heading-hierarchy` (CSH001): Enforce h2 start, no h1, no depth skips
9
9
  * - `changeset-required-sections` (CSH002): Validate section headings match known categories
10
10
  * - `changeset-content-structure` (CSH003): Content quality validation
11
+ * - `changeset-uncategorized-content` (CSH004): Reject content before first h2 heading
11
12
  *
12
13
  * @packageDocumentation
13
14
  */
@@ -58,4 +59,12 @@ export declare const RequiredSectionsRule: Rule;
58
59
  declare const SilkChangesetsRules: Rule[];
59
60
  export default SilkChangesetsRules;
60
61
 
62
+ /**
63
+ * markdownlint rule: changeset-uncategorized-content (CSH004)
64
+ *
65
+ * Detects content that appears before the first h2 heading in a changeset file.
66
+ * All content must be placed under a categorized section (## heading).
67
+ */
68
+ export declare const UncategorizedContentRule: Rule;
69
+
61
70
  export { }
package/cjs/remark.cjs CHANGED
@@ -24,6 +24,7 @@ var __webpack_require__ = {};
24
24
  var __webpack_exports__ = {};
25
25
  __webpack_require__.r(__webpack_exports__);
26
26
  __webpack_require__.d(__webpack_exports__, {
27
+ UncategorizedContentRule: ()=>UncategorizedContentRule,
27
28
  SilkChangesetTransformPreset: ()=>SilkChangesetTransformPreset,
28
29
  NormalizeFormatPlugin: ()=>NormalizeFormatPlugin,
29
30
  IssueLinkRefsPlugin: ()=>IssueLinkRefsPlugin,
@@ -473,25 +474,32 @@ const ReorderSectionsPlugin = ()=>(tree)=>{
473
474
  }
474
475
  };
475
476
  const external_unified_lint_rule_namespaceObject = require("unified-lint-rule");
477
+ const DOCS_BASE = "https://github.com/savvy-web/changesets/blob/main/docs/rules";
478
+ const RULE_DOCS = {
479
+ CSH001: `${DOCS_BASE}/CSH001.md`,
480
+ CSH002: `${DOCS_BASE}/CSH002.md`,
481
+ CSH003: `${DOCS_BASE}/CSH003.md`,
482
+ CSH004: `${DOCS_BASE}/CSH004.md`
483
+ };
476
484
  const ContentStructureRule = (0, external_unified_lint_rule_namespaceObject.lintRule)("remark-lint:changeset-content-structure", (tree, file)=>{
477
485
  (0, external_unist_util_visit_namespaceObject.visit)(tree, "heading", (node, index, parent)=>{
478
486
  if (2 !== node.depth || null == parent || null == index) return;
479
487
  const next = parent.children[index + 1];
480
- if (!next || "heading" === next.type && 2 === next.depth) file.message("Empty section: heading has no content before the next section or end of file", node);
488
+ if (!next || "heading" === next.type && 2 === next.depth) file.message(`Empty section: heading has no content before the next section or end of file. Add a list of changes (e.g., "- Added feature X") under this heading, or remove the empty heading. See: ${RULE_DOCS.CSH003}`, node);
481
489
  });
482
490
  (0, external_unist_util_visit_namespaceObject.visit)(tree, "code", (node)=>{
483
- if (!node.lang) file.message("Code block is missing a language identifier", node);
491
+ if (!node.lang) file.message(`Code block is missing a language identifier. Add a language after the opening fence (e.g., \`\`\`ts, \`\`\`json, \`\`\`bash). See: ${RULE_DOCS.CSH003}`, node);
484
492
  });
485
493
  (0, external_unist_util_visit_namespaceObject.visit)(tree, "listItem", (node)=>{
486
494
  const text = (0, external_mdast_util_to_string_namespaceObject.toString)(node).trim();
487
- if (!text) file.message("Empty list item", node);
495
+ if (!text) file.message(`Empty list item. Each list item must contain descriptive text (e.g., "- Fixed login timeout issue"). See: ${RULE_DOCS.CSH003}`, node);
488
496
  });
489
497
  });
490
498
  const HeadingHierarchyRule = (0, external_unified_lint_rule_namespaceObject.lintRule)("remark-lint:changeset-heading-hierarchy", (tree, file)=>{
491
499
  let prevDepth = 0;
492
500
  (0, external_unist_util_visit_namespaceObject.visit)(tree, "heading", (node)=>{
493
- if (1 === node.depth) return void file.message("h1 headings are not allowed in changeset files", node);
494
- if (prevDepth > 0 && node.depth > prevDepth + 1) file.message(`Heading level skipped: expected h${prevDepth + 1} or lower, found h${node.depth}`, node);
501
+ if (1 === node.depth) return void file.message(`h1 headings are not allowed in changeset files. Use h2 (##) for top-level sections like "## Features" or "## Bug Fixes". h1 is reserved for the version title generated by the changelog formatter. See: ${RULE_DOCS.CSH001}`, node);
502
+ if (prevDepth > 0 && node.depth > prevDepth + 1) file.message(`Heading level skipped: expected h${prevDepth + 1} or lower, found h${node.depth}. Headings must increase sequentially (h2 → h3 → h4). Add the missing intermediate level or reduce this heading's depth. See: ${RULE_DOCS.CSH001}`, node);
495
503
  prevDepth = node.depth;
496
504
  });
497
505
  });
@@ -499,13 +507,27 @@ const RequiredSectionsRule = (0, external_unified_lint_rule_namespaceObject.lint
499
507
  (0, external_unist_util_visit_namespaceObject.visit)(tree, "heading", (node)=>{
500
508
  if (2 !== node.depth) return;
501
509
  const text = (0, external_mdast_util_to_string_namespaceObject.toString)(node);
502
- if (!isValidHeading(text)) file.message(`Unknown section "${text}". Valid sections: ${allHeadings().join(", ")}`, node);
510
+ if (!isValidHeading(text)) file.message(`Unknown section "${text}". Valid h2 headings are: ${allHeadings().join(", ")}. Heading comparison is case-insensitive. See: ${RULE_DOCS.CSH002}`, node);
503
511
  });
504
512
  });
513
+ const IGNORED_TYPES = new Set([
514
+ "html"
515
+ ]);
516
+ function isContentNode(node) {
517
+ if ("heading" === node.type) return false;
518
+ return !IGNORED_TYPES.has(node.type);
519
+ }
520
+ const UncategorizedContentRule = (0, external_unified_lint_rule_namespaceObject.lintRule)("remark-lint:changeset-uncategorized-content", (tree, file)=>{
521
+ for (const node of tree.children){
522
+ if ("heading" === node.type && 2 === node.depth) break;
523
+ if (isContentNode(node)) file.message(`Content must be placed under a category heading (## heading). Move this content under an appropriate section like "## Features" or "## Bug Fixes". If it doesn't fit an existing category, use "## Other". See: ${RULE_DOCS.CSH004}`, node);
524
+ }
525
+ });
505
526
  const SilkChangesetPreset = [
506
527
  HeadingHierarchyRule,
507
528
  RequiredSectionsRule,
508
- ContentStructureRule
529
+ ContentStructureRule,
530
+ UncategorizedContentRule
509
531
  ];
510
532
  const SilkChangesetTransformPreset = [
511
533
  MergeSectionsPlugin,
@@ -526,6 +548,7 @@ exports.ReorderSectionsPlugin = __webpack_exports__.ReorderSectionsPlugin;
526
548
  exports.RequiredSectionsRule = __webpack_exports__.RequiredSectionsRule;
527
549
  exports.SilkChangesetPreset = __webpack_exports__.SilkChangesetPreset;
528
550
  exports.SilkChangesetTransformPreset = __webpack_exports__.SilkChangesetTransformPreset;
551
+ exports.UncategorizedContentRule = __webpack_exports__.UncategorizedContentRule;
529
552
  for(var __rspack_i in __webpack_exports__)if (-1 === [
530
553
  "ContentStructureRule",
531
554
  "ContributorFootnotesPlugin",
@@ -537,7 +560,8 @@ for(var __rspack_i in __webpack_exports__)if (-1 === [
537
560
  "ReorderSectionsPlugin",
538
561
  "RequiredSectionsRule",
539
562
  "SilkChangesetPreset",
540
- "SilkChangesetTransformPreset"
563
+ "SilkChangesetTransformPreset",
564
+ "UncategorizedContentRule"
541
565
  ].indexOf(__rspack_i)) exports[__rspack_i] = __webpack_exports__[__rspack_i];
542
566
  Object.defineProperty(exports, '__esModule', {
543
567
  value: true
package/cjs/remark.d.cts CHANGED
@@ -71,7 +71,7 @@ export declare const RequiredSectionsRule: Plugin_2<Root, unknown>;
71
71
  *
72
72
  * @public
73
73
  */
74
- export declare const SilkChangesetPreset: readonly [Plugin_2<Root, unknown>, Plugin_2<Root, unknown>, Plugin_2<Root, unknown>];
74
+ export declare const SilkChangesetPreset: readonly [Plugin_2<Root, unknown>, Plugin_2<Root, unknown>, Plugin_2<Root, unknown>, Plugin_2<Root, unknown>];
75
75
 
76
76
  /**
77
77
  * Ordered array of all transform plugins in the correct execution order.
@@ -89,4 +89,6 @@ export declare const SilkChangesetPreset: readonly [Plugin_2<Root, unknown>, Plu
89
89
  */
90
90
  export declare const SilkChangesetTransformPreset: readonly [Plugin<[], Root>, Plugin<[], Root>, Plugin<[], Root>, Plugin<[], Root>, Plugin<[], Root>, Plugin<[], Root>];
91
91
 
92
+ export declare const UncategorizedContentRule: Plugin_2<Root, unknown>;
93
+
92
94
  export { }
package/esm/160.js CHANGED
@@ -143,7 +143,7 @@ function isValidUrl(value) {
143
143
  }
144
144
  }
145
145
  const UsernameSchema = Schema.String.pipe(Schema.pattern(/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/, {
146
- message: ()=>"Invalid GitHub username format"
146
+ message: ()=>'Invalid GitHub username format. Usernames must contain only alphanumeric characters and hyphens, and cannot start or end with a hyphen. Example: "octocat" or "my-user-123"'
147
147
  }));
148
148
  const IssueNumberSchema = PositiveInteger.annotations({
149
149
  title: "IssueNumber",
@@ -154,7 +154,7 @@ const UrlOrMarkdownLinkSchema = Schema.String.pipe(Schema.filter((value)=>{
154
154
  const match = MARKDOWN_LINK_PATTERN.exec(value);
155
155
  return match?.[2] ? isValidUrl(match[2]) : false;
156
156
  }, {
157
- message: ()=>"Invalid URL or markdown link format"
157
+ message: ()=>'Value must be a valid URL or a markdown link. Expected a plain URL (e.g., "https://github.com/owner/repo/pull/42") or a markdown link (e.g., "[#42](https://github.com/owner/repo/pull/42)")'
158
158
  }));
159
159
  const GitHubInfoSchema = Schema.Struct({
160
160
  user: Schema.optional(UsernameSchema),
package/esm/260.js ADDED
@@ -0,0 +1,8 @@
1
+ const DOCS_BASE = "https://github.com/savvy-web/changesets/blob/main/docs/rules";
2
+ const RULE_DOCS = {
3
+ CSH001: `${DOCS_BASE}/CSH001.md`,
4
+ CSH002: `${DOCS_BASE}/CSH002.md`,
5
+ CSH003: `${DOCS_BASE}/CSH003.md`,
6
+ CSH004: `${DOCS_BASE}/CSH004.md`
7
+ };
8
+ export { RULE_DOCS };
package/esm/273.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
2
  import { join, relative, resolve } from "node:path";
3
3
  import { unified, remark_gfm, remark_stringify, remark_parse } from "./795.js";
4
- import { MergeSectionsPlugin, DeduplicateItemsPlugin, IssueLinkRefsPlugin, ContentStructureRule, HeadingHierarchyRule, NormalizeFormatPlugin, ReorderSectionsPlugin, ContributorFootnotesPlugin, RequiredSectionsRule } from "./234.js";
4
+ import { UncategorizedContentRule, MergeSectionsPlugin, DeduplicateItemsPlugin, IssueLinkRefsPlugin, ContentStructureRule, HeadingHierarchyRule, NormalizeFormatPlugin, ReorderSectionsPlugin, ContributorFootnotesPlugin, RequiredSectionsRule } from "./622.js";
5
5
  function stripFrontmatter(content) {
6
6
  return content.replace(/^---\n[\s\S]*?\n---\n?/, "");
7
7
  }
@@ -12,7 +12,7 @@ class ChangesetLinter {
12
12
  }
13
13
  static validateContent(content, filePath = "<input>") {
14
14
  const body = stripFrontmatter(content);
15
- const processor = unified().use(remark_parse).use(remark_stringify).use(HeadingHierarchyRule).use(RequiredSectionsRule).use(ContentStructureRule);
15
+ const processor = unified().use(remark_parse).use(remark_stringify).use(HeadingHierarchyRule).use(RequiredSectionsRule).use(ContentStructureRule).use(UncategorizedContentRule);
16
16
  const file = processor.processSync(body);
17
17
  return file.messages.map((msg)=>({
18
18
  file: filePath,
@@ -1,26 +1,27 @@
1
1
  import { lintRule } from "unified-lint-rule";
2
2
  import { SKIP, visit } from "unist-util-visit";
3
3
  import { external_mdast_util_to_string_toString } from "./689.js";
4
+ import { RULE_DOCS } from "./260.js";
4
5
  import { isValidHeading, fromHeading, allHeadings } from "./60.js";
5
6
  const ContentStructureRule = lintRule("remark-lint:changeset-content-structure", (tree, file)=>{
6
7
  visit(tree, "heading", (node, index, parent)=>{
7
8
  if (2 !== node.depth || null == parent || null == index) return;
8
9
  const next = parent.children[index + 1];
9
- if (!next || "heading" === next.type && 2 === next.depth) file.message("Empty section: heading has no content before the next section or end of file", node);
10
+ if (!next || "heading" === next.type && 2 === next.depth) file.message(`Empty section: heading has no content before the next section or end of file. Add a list of changes (e.g., "- Added feature X") under this heading, or remove the empty heading. See: ${RULE_DOCS.CSH003}`, node);
10
11
  });
11
12
  visit(tree, "code", (node)=>{
12
- if (!node.lang) file.message("Code block is missing a language identifier", node);
13
+ if (!node.lang) file.message(`Code block is missing a language identifier. Add a language after the opening fence (e.g., \`\`\`ts, \`\`\`json, \`\`\`bash). See: ${RULE_DOCS.CSH003}`, node);
13
14
  });
14
15
  visit(tree, "listItem", (node)=>{
15
16
  const text = external_mdast_util_to_string_toString(node).trim();
16
- if (!text) file.message("Empty list item", node);
17
+ if (!text) file.message(`Empty list item. Each list item must contain descriptive text (e.g., "- Fixed login timeout issue"). See: ${RULE_DOCS.CSH003}`, node);
17
18
  });
18
19
  });
19
20
  const HeadingHierarchyRule = lintRule("remark-lint:changeset-heading-hierarchy", (tree, file)=>{
20
21
  let prevDepth = 0;
21
22
  visit(tree, "heading", (node)=>{
22
- if (1 === node.depth) return void file.message("h1 headings are not allowed in changeset files", node);
23
- if (prevDepth > 0 && node.depth > prevDepth + 1) file.message(`Heading level skipped: expected h${prevDepth + 1} or lower, found h${node.depth}`, node);
23
+ if (1 === node.depth) return void file.message(`h1 headings are not allowed in changeset files. Use h2 (##) for top-level sections like "## Features" or "## Bug Fixes". h1 is reserved for the version title generated by the changelog formatter. See: ${RULE_DOCS.CSH001}`, node);
24
+ if (prevDepth > 0 && node.depth > prevDepth + 1) file.message(`Heading level skipped: expected h${prevDepth + 1} or lower, found h${node.depth}. Headings must increase sequentially (h2 → h3 → h4). Add the missing intermediate level or reduce this heading's depth. See: ${RULE_DOCS.CSH001}`, node);
24
25
  prevDepth = node.depth;
25
26
  });
26
27
  });
@@ -28,9 +29,22 @@ const RequiredSectionsRule = lintRule("remark-lint:changeset-required-sections",
28
29
  visit(tree, "heading", (node)=>{
29
30
  if (2 !== node.depth) return;
30
31
  const text = external_mdast_util_to_string_toString(node);
31
- if (!isValidHeading(text)) file.message(`Unknown section "${text}". Valid sections: ${allHeadings().join(", ")}`, node);
32
+ if (!isValidHeading(text)) file.message(`Unknown section "${text}". Valid h2 headings are: ${allHeadings().join(", ")}. Heading comparison is case-insensitive. See: ${RULE_DOCS.CSH002}`, node);
32
33
  });
33
34
  });
35
+ const IGNORED_TYPES = new Set([
36
+ "html"
37
+ ]);
38
+ function isContentNode(node) {
39
+ if ("heading" === node.type) return false;
40
+ return !IGNORED_TYPES.has(node.type);
41
+ }
42
+ const UncategorizedContentRule = lintRule("remark-lint:changeset-uncategorized-content", (tree, file)=>{
43
+ for (const node of tree.children){
44
+ if ("heading" === node.type && 2 === node.depth) break;
45
+ if (isContentNode(node)) file.message(`Content must be placed under a category heading (## heading). Move this content under an appropriate section like "## Features" or "## Bug Fixes". If it doesn't fit an existing category, use "## Other". See: ${RULE_DOCS.CSH004}`, node);
46
+ }
47
+ });
34
48
  function getVersionBlocks(tree) {
35
49
  const blocks = [];
36
50
  for(let i = 0; i < tree.children.length; i++){
@@ -334,4 +348,4 @@ const ReorderSectionsPlugin = ()=>(tree)=>{
334
348
  tree.children.splice(block.startIndex, blockLength, ...newChildren);
335
349
  }
336
350
  };
337
- export { ContentStructureRule, ContributorFootnotesPlugin, DeduplicateItemsPlugin, HeadingHierarchyRule, IssueLinkRefsPlugin, MergeSectionsPlugin, NormalizeFormatPlugin, ReorderSectionsPlugin, RequiredSectionsRule };
351
+ export { ContentStructureRule, ContributorFootnotesPlugin, DeduplicateItemsPlugin, HeadingHierarchyRule, IssueLinkRefsPlugin, MergeSectionsPlugin, NormalizeFormatPlugin, ReorderSectionsPlugin, RequiredSectionsRule, UncategorizedContentRule };
@@ -2,8 +2,8 @@
2
2
  import { Args, Command, Options } from "@effect/cli";
3
3
  import { NodeContext, NodeRuntime } from "@effect/platform-node";
4
4
  import { execSync } from "node:child_process";
5
+ import { applyEdits, modify, parse } from "jsonc-parser";
5
6
  import { findProjectRoot, getWorkspaceInfos } from "workspace-tools";
6
- import { parse } from "jsonc-parser";
7
7
  import { globSync } from "tinyglobby";
8
8
  import { readFileSync, ChangelogTransformer, ChangesetLinter, resolve, existsSync, relative, mkdirSync, writeFileSync, join } from "../273.js";
9
9
  import { VersionFilesSchema, Schema, VersionFileError, Data, Effect } from "../795.js";
@@ -48,7 +48,8 @@ const MARKDOWNLINT_CONFIG_PATHS = [
48
48
  const RULE_NAMES = [
49
49
  "changeset-heading-hierarchy",
50
50
  "changeset-required-sections",
51
- "changeset-content-structure"
51
+ "changeset-content-structure",
52
+ "changeset-uncategorized-content"
52
53
  ];
53
54
  const DEFAULT_CONFIG = {
54
55
  $schema: "https://unpkg.com/@changesets/config@3.1.1/schema.json",
@@ -91,9 +92,10 @@ function detectGitHubRepo(cwd) {
91
92
  } catch {}
92
93
  return null;
93
94
  }
94
- function stripJsoncComments(text) {
95
- return text.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
96
- }
95
+ const JSONC_FORMAT = {
96
+ tabSize: 1,
97
+ insertSpaces: false
98
+ };
97
99
  function resolveWorkspaceRoot(cwd) {
98
100
  return findProjectRoot(cwd) ?? cwd;
99
101
  }
@@ -155,13 +157,38 @@ function handleBaseMarkdownlint(root) {
155
157
  const foundPath = findMarkdownlintConfig(root);
156
158
  if (!foundPath) return `Warning: no markdownlint config found (checked ${MARKDOWNLINT_CONFIG_PATHS.join(", ")})`;
157
159
  const fullPath = join(root, foundPath);
158
- const raw = readFileSync(fullPath, "utf-8");
159
- const parsed = JSON.parse(stripJsoncComments(raw));
160
- if (!Array.isArray(parsed.customRules)) parsed.customRules = [];
161
- if (!parsed.customRules.includes(CUSTOM_RULES_ENTRY)) parsed.customRules.push(CUSTOM_RULES_ENTRY);
162
- if ("object" != typeof parsed.config || null === parsed.config) parsed.config = {};
163
- for (const rule of RULE_NAMES)if (!(rule in parsed.config)) parsed.config[rule] = false;
164
- writeFileSync(fullPath, `${JSON.stringify(parsed, null, "\t")}\n`);
160
+ let text = readFileSync(fullPath, "utf-8");
161
+ const parsed = parse(text);
162
+ if (!Array.isArray(parsed.customRules) || !parsed.customRules.includes(CUSTOM_RULES_ENTRY)) {
163
+ const edits = modify(text, [
164
+ "customRules",
165
+ -1
166
+ ], CUSTOM_RULES_ENTRY, {
167
+ formattingOptions: JSONC_FORMAT,
168
+ isArrayInsertion: true
169
+ });
170
+ text = applyEdits(text, edits);
171
+ }
172
+ const currentConfig = parse(text).config;
173
+ if ("object" != typeof currentConfig || null === currentConfig) {
174
+ const edits = modify(text, [
175
+ "config"
176
+ ], {}, {
177
+ formattingOptions: JSONC_FORMAT
178
+ });
179
+ text = applyEdits(text, edits);
180
+ }
181
+ const config = parse(text).config;
182
+ for (const rule of RULE_NAMES)if (!(rule in config)) {
183
+ const edits = modify(text, [
184
+ "config",
185
+ rule
186
+ ], false, {
187
+ formattingOptions: JSONC_FORMAT
188
+ });
189
+ text = applyEdits(text, edits);
190
+ }
191
+ writeFileSync(fullPath, text);
165
192
  return `Updated ${foundPath}`;
166
193
  },
167
194
  catch: (error)=>new InitError({
@@ -247,7 +274,7 @@ function checkBaseMarkdownlint(root) {
247
274
  ];
248
275
  try {
249
276
  const raw = readFileSync(join(root, foundPath), "utf-8");
250
- const parsed = JSON.parse(stripJsoncComments(raw));
277
+ const parsed = parse(raw);
251
278
  const issues = [];
252
279
  if (!Array.isArray(parsed.customRules) || !parsed.customRules.includes(CUSTOM_RULES_ENTRY)) issues.push({
253
280
  file: foundPath,
@@ -758,7 +785,7 @@ const rootCommand = Command.make("savvy-changesets").pipe(Command.withSubcommand
758
785
  ]));
759
786
  const cli = Command.run(rootCommand, {
760
787
  name: "savvy-changesets",
761
- version: "0.2.1"
788
+ version: "0.4.0"
762
789
  });
763
790
  function runCli() {
764
791
  const main = Effect.suspend(()=>cli(process.argv)).pipe(Effect.provide(NodeContext.layer));
package/esm/index.d.ts CHANGED
@@ -256,9 +256,9 @@ export declare interface Changeset extends Schema.Schema.Type<typeof ChangesetSc
256
256
  /**
257
257
  * Static class for linting changeset files.
258
258
  *
259
- * Runs the three remark-lint rules (heading-hierarchy, required-sections,
260
- * content-structure) against changeset markdown and returns structured
261
- * diagnostic messages.
259
+ * Runs the four remark-lint rules (heading-hierarchy, required-sections,
260
+ * content-structure, uncategorized-content) against changeset markdown
261
+ * and returns structured diagnostic messages.
262
262
  *
263
263
  * @example
264
264
  * ```typescript
package/esm/index.js CHANGED
@@ -48,13 +48,13 @@ const SectionCategorySchema = Schema.Struct({
48
48
  description: Schema.String
49
49
  });
50
50
  const CommitHashSchema = Schema.String.pipe(Schema.pattern(/^[a-f0-9]{7,}$/, {
51
- message: ()=>"Commit hash must be at least 7 hexadecimal characters"
51
+ message: ()=>'Commit hash must be 7 or more lowercase hexadecimal characters (0-9, a-f). Example: "a1b2c3d" or a full 40-character SHA like "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2". Uppercase letters are not allowed'
52
52
  }));
53
53
  const VersionTypeSchema = Schema.Literal("major", "minor", "patch", "none");
54
54
  const ChangesetSummarySchema = Schema.String.pipe(Schema.minLength(1, {
55
- message: ()=>"Changeset summary cannot be empty"
55
+ message: ()=>'Changeset summary cannot be empty. Provide a 1-1000 character description of the change (e.g., "Fix authentication timeout in login flow")'
56
56
  }), Schema.maxLength(1000, {
57
- message: ()=>"Changeset summary is too long"
57
+ message: ()=>"Changeset summary exceeds the 1000 character limit. Shorten the summary to at most 1000 characters — use the changeset body for additional details"
58
58
  }));
59
59
  const ChangesetSchema = Schema.Struct({
60
60
  summary: ChangesetSummarySchema,
@@ -8,6 +8,7 @@
8
8
  * - `changeset-heading-hierarchy` (CSH001): Enforce h2 start, no h1, no depth skips
9
9
  * - `changeset-required-sections` (CSH002): Validate section headings match known categories
10
10
  * - `changeset-content-structure` (CSH003): Content quality validation
11
+ * - `changeset-uncategorized-content` (CSH004): Reject content before first h2 heading
11
12
  *
12
13
  * @packageDocumentation
13
14
  */
@@ -58,4 +59,12 @@ export declare const RequiredSectionsRule: Rule;
58
59
  declare const SilkChangesetsRules: Rule[];
59
60
  export default SilkChangesetsRules;
60
61
 
62
+ /**
63
+ * markdownlint rule: changeset-uncategorized-content (CSH004)
64
+ *
65
+ * Detects content that appears before the first h2 heading in a changeset file.
66
+ * All content must be placed under a categorized section (## heading).
67
+ */
68
+ export declare const UncategorizedContentRule: Rule;
69
+
61
70
  export { }
@@ -1,3 +1,4 @@
1
+ import { RULE_DOCS } from "./260.js";
1
2
  import { isValidHeading, allHeadings } from "./60.js";
2
3
  function getHeadingLevel(heading) {
3
4
  const sequence = heading.children.find((c)=>"atxHeadingSequence" === c.type);
@@ -33,7 +34,7 @@ const ContentStructureRule = {
33
34
  const nextIdx = i + 1 < h2Indices.length ? h2Indices[i + 1] : tokens.length;
34
35
  if (!hasContentBetween(tokens, currentIdx, nextIdx)) onError({
35
36
  lineNumber: tokens[currentIdx].startLine,
36
- detail: "Empty section: heading has no content before the next section or end of file"
37
+ detail: `Empty section: heading has no content before the next section or end of file. Add a list of changes (e.g., "- Added feature X") under this heading, or remove the empty heading. See: ${RULE_DOCS.CSH003}`
37
38
  });
38
39
  }
39
40
  for (const token of tokens){
@@ -42,7 +43,7 @@ const ContentStructureRule = {
42
43
  const hasInfo = openingFence?.children.some((c)=>"codeFencedFenceInfo" === c.type) ?? false;
43
44
  if (!hasInfo) onError({
44
45
  lineNumber: token.startLine,
45
- detail: "Code block is missing a language identifier"
46
+ detail: `Code block is missing a language identifier. Add a language after the opening fence (e.g., \`\`\`ts, \`\`\`json, \`\`\`bash). See: ${RULE_DOCS.CSH003}`
46
47
  });
47
48
  }
48
49
  for (const token of tokens){
@@ -60,7 +61,7 @@ const ContentStructureRule = {
60
61
  }
61
62
  if (!hasContent) onError({
62
63
  lineNumber: children[i].startLine,
63
- detail: "Empty list item"
64
+ detail: `Empty list item. Each list item must contain descriptive text (e.g., "- Fixed login timeout issue"). See: ${RULE_DOCS.CSH003}`
64
65
  });
65
66
  }
66
67
  }
@@ -84,13 +85,13 @@ const HeadingHierarchyRule = {
84
85
  if (1 === depth) {
85
86
  onError({
86
87
  lineNumber: token.startLine,
87
- detail: "h1 headings are not allowed in changeset files"
88
+ detail: `h1 headings are not allowed in changeset files. Use h2 (##) for top-level sections like "## Features" or "## Bug Fixes". h1 is reserved for the version title generated by the changelog formatter. See: ${RULE_DOCS.CSH001}`
88
89
  });
89
90
  continue;
90
91
  }
91
92
  if (prevDepth > 0 && depth > prevDepth + 1) onError({
92
93
  lineNumber: token.startLine,
93
- detail: `Heading level skipped: expected h${prevDepth + 1} or lower, found h${depth}`
94
+ detail: `Heading level skipped: expected h${prevDepth + 1} or lower, found h${depth}. Headings must increase sequentially (h2 → h3 → h4). Add the missing intermediate level or reduce this heading's depth. See: ${RULE_DOCS.CSH001}`
94
95
  });
95
96
  prevDepth = depth;
96
97
  }
@@ -113,16 +114,40 @@ const RequiredSectionsRule = {
113
114
  const text = getHeadingText(token);
114
115
  if (!isValidHeading(text)) onError({
115
116
  lineNumber: token.startLine,
116
- detail: `Unknown section "${text}". Valid sections: ${allHeadings().join(", ")}`
117
+ detail: `Unknown section "${text}". Valid h2 headings are: ${allHeadings().join(", ")}. Heading comparison is case-insensitive. See: ${RULE_DOCS.CSH002}`
117
118
  });
118
119
  }
119
120
  }
120
121
  };
122
+ const UncategorizedContentRule = {
123
+ names: [
124
+ "changeset-uncategorized-content",
125
+ "CSH004"
126
+ ],
127
+ description: "All content must be placed under a category heading (## heading)",
128
+ tags: [
129
+ "changeset"
130
+ ],
131
+ parser: "micromark",
132
+ function: function(params, onError) {
133
+ const tokens = params.parsers.micromark.tokens;
134
+ for (const token of tokens){
135
+ if ("atxHeading" === token.type && 2 === getHeadingLevel(token)) break;
136
+ if ("lineEnding" !== token.type && "lineEndingBlank" !== token.type && "htmlFlow" !== token.type) {
137
+ if ("atxHeading" !== token.type) onError({
138
+ lineNumber: token.startLine,
139
+ detail: `Content must be placed under a category heading (## heading). Move this content under an appropriate section like "## Features" or "## Bug Fixes". If it doesn't fit an existing category, use "## Other". See: ${RULE_DOCS.CSH004}`
140
+ });
141
+ }
142
+ }
143
+ }
144
+ };
121
145
  const SilkChangesetsRules = [
122
146
  HeadingHierarchyRule,
123
147
  RequiredSectionsRule,
124
- ContentStructureRule
148
+ ContentStructureRule,
149
+ UncategorizedContentRule
125
150
  ];
126
151
  const markdownlint = SilkChangesetsRules;
127
152
  export default markdownlint;
128
- export { ContentStructureRule, HeadingHierarchyRule, RequiredSectionsRule };
153
+ export { ContentStructureRule, HeadingHierarchyRule, RequiredSectionsRule, UncategorizedContentRule };
package/esm/remark.d.ts CHANGED
@@ -71,7 +71,7 @@ export declare const RequiredSectionsRule: Plugin_2<Root, unknown>;
71
71
  *
72
72
  * @public
73
73
  */
74
- export declare const SilkChangesetPreset: readonly [Plugin_2<Root, unknown>, Plugin_2<Root, unknown>, Plugin_2<Root, unknown>];
74
+ export declare const SilkChangesetPreset: readonly [Plugin_2<Root, unknown>, Plugin_2<Root, unknown>, Plugin_2<Root, unknown>, Plugin_2<Root, unknown>];
75
75
 
76
76
  /**
77
77
  * Ordered array of all transform plugins in the correct execution order.
@@ -89,4 +89,6 @@ export declare const SilkChangesetPreset: readonly [Plugin_2<Root, unknown>, Plu
89
89
  */
90
90
  export declare const SilkChangesetTransformPreset: readonly [Plugin<[], Root>, Plugin<[], Root>, Plugin<[], Root>, Plugin<[], Root>, Plugin<[], Root>, Plugin<[], Root>];
91
91
 
92
+ export declare const UncategorizedContentRule: Plugin_2<Root, unknown>;
93
+
92
94
  export { }
package/esm/remark.js CHANGED
@@ -1,8 +1,9 @@
1
- import { NormalizeFormatPlugin, DeduplicateItemsPlugin, MergeSectionsPlugin, ContentStructureRule, IssueLinkRefsPlugin, ReorderSectionsPlugin, HeadingHierarchyRule, ContributorFootnotesPlugin, RequiredSectionsRule } from "./234.js";
1
+ import { UncategorizedContentRule, NormalizeFormatPlugin, DeduplicateItemsPlugin, MergeSectionsPlugin, ContentStructureRule, IssueLinkRefsPlugin, ReorderSectionsPlugin, HeadingHierarchyRule, ContributorFootnotesPlugin, RequiredSectionsRule } from "./622.js";
2
2
  const SilkChangesetPreset = [
3
3
  HeadingHierarchyRule,
4
4
  RequiredSectionsRule,
5
- ContentStructureRule
5
+ ContentStructureRule,
6
+ UncategorizedContentRule
6
7
  ];
7
8
  const SilkChangesetTransformPreset = [
8
9
  MergeSectionsPlugin,
@@ -12,5 +13,5 @@ const SilkChangesetTransformPreset = [
12
13
  IssueLinkRefsPlugin,
13
14
  NormalizeFormatPlugin
14
15
  ];
15
- export { ContentStructureRule, ContributorFootnotesPlugin, DeduplicateItemsPlugin, HeadingHierarchyRule, IssueLinkRefsPlugin, MergeSectionsPlugin, NormalizeFormatPlugin, ReorderSectionsPlugin, RequiredSectionsRule } from "./234.js";
16
+ export { ContentStructureRule, ContributorFootnotesPlugin, DeduplicateItemsPlugin, HeadingHierarchyRule, IssueLinkRefsPlugin, MergeSectionsPlugin, NormalizeFormatPlugin, ReorderSectionsPlugin, RequiredSectionsRule, UncategorizedContentRule } from "./622.js";
16
17
  export { SilkChangesetPreset, SilkChangesetTransformPreset };
package/package.json CHANGED
@@ -1,98 +1,98 @@
1
1
  {
2
- "name": "@savvy-web/changesets",
3
- "version": "0.2.1",
4
- "private": false,
5
- "description": "Custom changelog formatter and markdown processing pipeline for the Silk Suite. Provides structured changeset sections, remark-based validation and transformation, and an Effect CLI.",
6
- "keywords": [
7
- "changesets",
8
- "changelog",
9
- "release-notes",
10
- "remark",
11
- "markdown",
12
- "effect",
13
- "silk-suite"
14
- ],
15
- "homepage": "https://github.com/savvy-web/changesets#readme",
16
- "bugs": {
17
- "url": "https://github.com/savvy-web/changesets/issues"
18
- },
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/savvy-web/changesets.git"
22
- },
23
- "license": "MIT",
24
- "author": {
25
- "name": "C. Spencer Beggs",
26
- "email": "spencer@savvyweb.systems",
27
- "url": "https://savvyweb.systems"
28
- },
29
- "type": "module",
30
- "exports": {
31
- ".": {
32
- "types": "./esm/index.d.ts",
33
- "import": "./esm/index.js",
34
- "require": "./cjs/index.cjs"
35
- },
36
- "./changelog": {
37
- "types": "./esm/changelog.d.ts",
38
- "import": "./esm/changelog.js",
39
- "require": "./cjs/changelog.cjs"
40
- },
41
- "./markdownlint": {
42
- "types": "./esm/markdownlint.d.ts",
43
- "import": "./esm/markdownlint.js",
44
- "require": "./cjs/markdownlint.cjs"
45
- },
46
- "./remark": {
47
- "types": "./esm/remark.d.ts",
48
- "import": "./esm/remark.js",
49
- "require": "./cjs/remark.cjs"
50
- }
51
- },
52
- "bin": {
53
- "savvy-changesets": "./esm/bin/savvy-changesets.js"
54
- },
55
- "dependencies": {
56
- "@changesets/get-github-info": "^0.7.0",
57
- "@effect/cli": "^0.73.2",
58
- "@effect/platform": "^0.94.5",
59
- "@effect/platform-node": "^0.104.1",
60
- "effect": "^3.19.19",
61
- "jsonc-parser": "^3.3.1",
62
- "mdast-util-heading-range": "^4.0.0",
63
- "mdast-util-to-string": "^4.0.0",
64
- "remark-gfm": "^4.0.1",
65
- "remark-parse": "^11.0.0",
66
- "remark-stringify": "^11.0.0",
67
- "tinyglobby": "^0.2.15",
68
- "unified": "^11.0.5",
69
- "unified-lint-rule": "^3.0.1",
70
- "unist-util-visit": "^5.1.0",
71
- "workspace-tools": "^0.41.0"
72
- },
73
- "peerDependencies": {
74
- "@changesets/cli": "^2.29.8"
75
- },
76
- "peerDependenciesMeta": {
77
- "@changesets/cli": {
78
- "optional": false
79
- }
80
- },
81
- "engines": {
82
- "node": ">=24.0.0"
83
- },
84
- "scripts": {
85
- "postinstall": "savvy-changesets init --check"
86
- },
87
- "files": [
88
- "!changesets.api.json",
89
- "!tsconfig.json",
90
- "!tsdoc.json",
91
- "LICENSE",
92
- "README.md",
93
- "cjs",
94
- "esm",
95
- "package.json",
96
- "tsdoc-metadata.json"
97
- ]
98
- }
2
+ "name": "@savvy-web/changesets",
3
+ "version": "0.4.0",
4
+ "private": false,
5
+ "description": "Custom changelog formatter and markdown processing pipeline for the Silk Suite. Provides structured changeset sections, remark-based validation and transformation, and an Effect CLI.",
6
+ "keywords": [
7
+ "changesets",
8
+ "changelog",
9
+ "release-notes",
10
+ "remark",
11
+ "markdown",
12
+ "effect",
13
+ "silk-suite"
14
+ ],
15
+ "homepage": "https://github.com/savvy-web/changesets#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/savvy-web/changesets/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/savvy-web/changesets.git"
22
+ },
23
+ "license": "MIT",
24
+ "author": {
25
+ "name": "C. Spencer Beggs",
26
+ "email": "spencer@savvyweb.systems",
27
+ "url": "https://savvyweb.systems"
28
+ },
29
+ "type": "module",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./esm/index.d.ts",
33
+ "import": "./esm/index.js",
34
+ "require": "./cjs/index.cjs"
35
+ },
36
+ "./changelog": {
37
+ "types": "./esm/changelog.d.ts",
38
+ "import": "./esm/changelog.js",
39
+ "require": "./cjs/changelog.cjs"
40
+ },
41
+ "./markdownlint": {
42
+ "types": "./esm/markdownlint.d.ts",
43
+ "import": "./esm/markdownlint.js",
44
+ "require": "./cjs/markdownlint.cjs"
45
+ },
46
+ "./remark": {
47
+ "types": "./esm/remark.d.ts",
48
+ "import": "./esm/remark.js",
49
+ "require": "./cjs/remark.cjs"
50
+ }
51
+ },
52
+ "bin": {
53
+ "savvy-changesets": "./esm/bin/savvy-changesets.js"
54
+ },
55
+ "dependencies": {
56
+ "@changesets/get-github-info": "^0.8.0",
57
+ "@effect/cli": "^0.73.2",
58
+ "@effect/platform": "^0.94.5",
59
+ "@effect/platform-node": "^0.104.1",
60
+ "effect": "^3.19.19",
61
+ "jsonc-parser": "^3.3.1",
62
+ "mdast-util-heading-range": "^4.0.0",
63
+ "mdast-util-to-string": "^4.0.0",
64
+ "remark-gfm": "^4.0.1",
65
+ "remark-parse": "^11.0.0",
66
+ "remark-stringify": "^11.0.0",
67
+ "tinyglobby": "^0.2.15",
68
+ "unified": "^11.0.5",
69
+ "unified-lint-rule": "^3.0.1",
70
+ "unist-util-visit": "^5.1.0",
71
+ "workspace-tools": "^0.41.0"
72
+ },
73
+ "peerDependencies": {
74
+ "@changesets/cli": "^2.30.0"
75
+ },
76
+ "peerDependenciesMeta": {
77
+ "@changesets/cli": {
78
+ "optional": false
79
+ }
80
+ },
81
+ "engines": {
82
+ "node": ">=24.0.0"
83
+ },
84
+ "scripts": {
85
+ "postinstall": "savvy-changesets init --check"
86
+ },
87
+ "files": [
88
+ "!changesets.api.json",
89
+ "!tsconfig.json",
90
+ "!tsdoc.json",
91
+ "LICENSE",
92
+ "README.md",
93
+ "cjs",
94
+ "esm",
95
+ "package.json",
96
+ "tsdoc-metadata.json"
97
+ ]
98
+ }