@rheged-studio/changelog-core 1.1.1

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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +92 -0
  3. package/changelog-contract.md +129 -0
  4. package/config.json +9 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +106 -0
  8. package/dist/commands/add-links.d.ts +8 -0
  9. package/dist/commands/add-links.d.ts.map +1 -0
  10. package/dist/commands/add-links.js +258 -0
  11. package/dist/commands/backfill-commits.d.ts +19 -0
  12. package/dist/commands/backfill-commits.d.ts.map +1 -0
  13. package/dist/commands/backfill-commits.js +236 -0
  14. package/dist/commands/check-completeness.d.ts +13 -0
  15. package/dist/commands/check-completeness.d.ts.map +1 -0
  16. package/dist/commands/check-completeness.js +164 -0
  17. package/dist/commands/enrich.d.ts +17 -0
  18. package/dist/commands/enrich.d.ts.map +1 -0
  19. package/dist/commands/enrich.js +194 -0
  20. package/dist/commands/finalise.d.ts +25 -0
  21. package/dist/commands/finalise.d.ts.map +1 -0
  22. package/dist/commands/finalise.js +249 -0
  23. package/dist/commands/set-affected-packages.d.ts +13 -0
  24. package/dist/commands/set-affected-packages.d.ts.map +1 -0
  25. package/dist/commands/set-affected-packages.js +207 -0
  26. package/dist/commands/validate.d.ts +6 -0
  27. package/dist/commands/validate.d.ts.map +1 -0
  28. package/dist/commands/validate.js +293 -0
  29. package/dist/index.d.ts +23 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +24 -0
  32. package/dist/lib/blank.d.ts +5 -0
  33. package/dist/lib/blank.d.ts.map +1 -0
  34. package/dist/lib/blank.js +6 -0
  35. package/dist/lib/changelog.d.ts +6 -0
  36. package/dist/lib/changelog.d.ts.map +1 -0
  37. package/dist/lib/changelog.js +47 -0
  38. package/dist/lib/cli-entry.d.ts +10 -0
  39. package/dist/lib/cli-entry.d.ts.map +1 -0
  40. package/dist/lib/cli-entry.js +22 -0
  41. package/dist/lib/commit-count.d.ts +16 -0
  42. package/dist/lib/commit-count.d.ts.map +1 -0
  43. package/dist/lib/commit-count.js +72 -0
  44. package/dist/lib/config.d.ts +25 -0
  45. package/dist/lib/config.d.ts.map +1 -0
  46. package/dist/lib/config.js +156 -0
  47. package/dist/lib/derive-packages.d.ts +10 -0
  48. package/dist/lib/derive-packages.d.ts.map +1 -0
  49. package/dist/lib/derive-packages.js +42 -0
  50. package/dist/lib/enrich.d.ts +29 -0
  51. package/dist/lib/enrich.d.ts.map +1 -0
  52. package/dist/lib/enrich.js +54 -0
  53. package/dist/lib/frontmatter.d.ts +11 -0
  54. package/dist/lib/frontmatter.d.ts.map +1 -0
  55. package/dist/lib/frontmatter.js +355 -0
  56. package/dist/lib/stamp.d.ts +10 -0
  57. package/dist/lib/stamp.d.ts.map +1 -0
  58. package/dist/lib/stamp.js +35 -0
  59. package/dist/lib/vendor/issue-keys.d.ts +16 -0
  60. package/dist/lib/vendor/issue-keys.d.ts.map +1 -0
  61. package/dist/lib/vendor/issue-keys.js +29 -0
  62. package/package.json +124 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Rob Easthope
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # @acme-skunkworks/changelog-core
2
+
3
+ Shared **changelog contract** and dual-runtime **CLI** for Rheged Studio —
4
+ validate, enrich, and finalise dated `changelog/` entries from CI **and** local
5
+ dev. One package replaces the triplicated skill `.mjs` + per-repo
6
+ `infrastructure/scripts` copies.
7
+
8
+ Zero runtime dependencies (Node built-ins only). Node 22+.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pnpm add -D @acme-skunkworks/changelog-core
14
+ ```
15
+
16
+ ## Config
17
+
18
+ Commands resolve config from the **consumer repo** (not the package install
19
+ path), in order:
20
+
21
+ 1. `--config <path>` (CLI)
22
+ 2. `CHANGELOG_CONFIG` env
23
+ 3. `.claude/skills/changelog/config.json`
24
+ 4. `.agents/skills/changelog/config.json`
25
+ 5. `config.json` (committed repo-root — stopgap while skill `config.json` stays
26
+ gitignored; see [A-812](https://linear.app/rheged-studio/issue/A-812))
27
+
28
+ Required identity keys: `issueKeys`, `linearWorkspaceSlug`. Structural keys
29
+ (`baseBranch`, `changelogDir`, `packageRoots`, `fallbackPackage`,
30
+ `affectedPackages`) have safe defaults.
31
+
32
+ ## CLI
33
+
34
+ ```bash
35
+ npx changelog-core [--config <path>] <subcommand> [args…]
36
+ npx changelog-core --version
37
+ ```
38
+
39
+ | Subcommand | Purpose |
40
+ | ----------------------- | ----------------------------------------------------------------- |
41
+ | `validate` | Schema-check every dated entry under `changelog/` |
42
+ | `enrich` | Fill post-merge fields for one entry (env-driven; deploy targets) |
43
+ | `finalise` | Release-time enrich + version stamp (npm targets) |
44
+ | `set-affected-packages` | Write `affected_packages` from the branch diff (monorepos) |
45
+ | `add-links` | Rewrite bare Linear IDs in bodies to links |
46
+ | `backfill-commits` | One-off backfill of `stats.commits` |
47
+ | `check-completeness` | Gate a release-triggering PR title on a changelog entry |
48
+
49
+ Local scripts in this repo:
50
+
51
+ ```bash
52
+ pnpm validate:changelog
53
+ pnpm changelog:enrich
54
+ pnpm changelog:finalise
55
+ ```
56
+
57
+ ## Contract
58
+
59
+ The frontmatter schema and field-ownership rules live in
60
+ [`changelog-contract.md`](changelog-contract.md) (also importable as
61
+ `@acme-skunkworks/changelog-core/contract`).
62
+
63
+ `merge_strategy` is **not** part of the contract (A-802). Existing entries that
64
+ still carry the key validate fine — unknown keys are ignored.
65
+
66
+ ## Library
67
+
68
+ ```ts
69
+ import {
70
+ validateEntry,
71
+ enrichFrontmatter,
72
+ parseFrontmatter,
73
+ loadConfig,
74
+ } from "@acme-skunkworks/changelog-core";
75
+ ```
76
+
77
+ See `src/index.ts` for the full public surface (frontmatter, config, enrich,
78
+ stamp, derive-packages, completeness helpers, …).
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ pnpm install
84
+ pnpm run build
85
+ pnpm test
86
+ pnpm lint
87
+ pnpm tsc
88
+ ```
89
+
90
+ ## Licence
91
+
92
+ MIT
@@ -0,0 +1,129 @@
1
+ # Changelog entry contract
2
+
3
+ The full frontmatter schema and field-ownership rules the changelog tooling
4
+ enforces. The `validate` command is the executable form of this contract.
5
+
6
+ ## Frontmatter schema
7
+
8
+ Preserve this field order. When `affectedPackages: true`, emit
9
+ `affected_packages: []` as a placeholder — enrichment fills it in place.
10
+ Single-package repos omit the field entirely.
11
+
12
+ ```yaml
13
+ ---
14
+ title: "Concise summary"
15
+ release_note: "One-sentence user-facing summary"
16
+ created_at: "2026-04-26T13:24:00Z"
17
+ merged_at:
18
+ branch: "<current-branch>"
19
+ pr:
20
+ commit:
21
+ author: "you@example.com"
22
+ co_authors: []
23
+ category: feature
24
+ breaking: false
25
+ issues: ["A-123"]
26
+ affected_packages: []
27
+ stats:
28
+ files_changed:
29
+ loc_added:
30
+ loc_removed:
31
+ commits:
32
+ ---
33
+ ```
34
+
35
+ ## Required fields
36
+
37
+ `validate` requires exactly four fields: `title`, `created_at`, `category`, and
38
+ `breaking`. Every other field — including `branch`, `author`, `co_authors`, and
39
+ `stats` — is validated **by type when present** but may be omitted or left as a
40
+ blank placeholder until enrichment.
41
+
42
+ ## Field types and rules
43
+
44
+ | Field | Rule |
45
+ | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
46
+ | `title` | Non-empty string. |
47
+ | `release_note` | String or `null`/blank when present. |
48
+ | `created_at` | ISO 8601 UTC with `Z` suffix, quoted. **Set once; never overwritten.** |
49
+ | `merged_at` | ISO 8601 UTC with `Z` suffix when set; blank until release. |
50
+ | `branch` | Non-empty string — the stable lookup key for enrichment. |
51
+ | `pr` | Integer when set; blank until post-merge enrichment resolves it from the merged PR. |
52
+ | `commit` | 7-char hex SHA when set; blank until merge. |
53
+ | `author` | Non-empty string (an email). |
54
+ | `co_authors` | Array of strings (`[]` when none). |
55
+ | `category` | One of `feature`, `fix`, `chore`, `docs`, `refactor`, `perf`. |
56
+ | `breaking` | Boolean. If `true`, the body MUST contain a `## Breaking` section. |
57
+ | `issues` | Array of strings, each matching `[A-Z]+-\d+` (a one-or-more-letter team key, e.g. `A-123`). |
58
+ | `affected_packages` | Array of strings (`[]` when unpopulated). Monorepo-gated — emitted only when `affectedPackages: true` in `config.json`; absent (and clean) for single-package repos. |
59
+ | `stats.{files_changed,loc_added,loc_removed,commits}` | Non-negative integers when set; blank until release. `commits` counts the PR's branch commits **excluding merge commits**, filled by post-merge enrichment. |
60
+
61
+ The filename must match `YYYYMMDD-HHMMSS-<slug>.md` (slug `[a-z0-9-]+`), and the
62
+ body must contain at least one of `## Breaking` / `## Added` / `## Changed` /
63
+ `## Fixed`.
64
+
65
+ ## Field ownership boundaries
66
+
67
+ Three owners, never overlapping:
68
+
69
+ 1. **The author (the changelog skill / ship flow).** `title`, `release_note`,
70
+ `category`, `breaking`, `issues`, `co_authors`, `author`. Re-derived on every
71
+ run.
72
+ 2. **The enrichment scripts (deterministic, pre-merge).** `affected_packages` —
73
+ when `affectedPackages: true`, `set-affected-packages` always overwrites it
74
+ from the latest branch diff, so it tracks added commits; when `false` (the
75
+ single-package default) the field is never emitted and the script is a no-op.
76
+ `add-links` rewrites bare issue IDs in the body to Linear links.
77
+ 3. **The release-orchestrator (post-merge, privileged).** `merged_at`, `commit`,
78
+ `pr`, and authoritative `stats`, plus the published `version` where a consumer
79
+ adds one. Emit these as blank placeholders; never hand-edit them — so an
80
+ in-flight PR never shows numbers that drift as commits land. `pr` is resolved
81
+ from the merged PR by the entry's `branch:` (the same branch-resolution
82
+ finalise/enrich use for the other post-merge fields) — the ship flow never
83
+ writes it. npm targets fill these at release time (`finalise`); deploy
84
+ targets, never checked out during the release flow, fill them afterwards from
85
+ the enrichment cron (`enrich`, minus `version` and `commits` — the cron
86
+ passes no commit count, so `stats.commits` is left to the release-time path).
87
+
88
+ `branch` is set by the author at create time and is the stable lookup key for
89
+ enrichment.
90
+
91
+ `created_at` is **sacred**: set once at create time, preserved verbatim on every
92
+ update. The release step refuses to finalise an entry without it.
93
+
94
+ ## Body structure
95
+
96
+ ```markdown
97
+ ## Breaking <!-- only when breaking: true -->
98
+
99
+ - Description and migration steps
100
+
101
+ ## Added
102
+
103
+ - Description
104
+
105
+ ## Changed
106
+
107
+ - ...
108
+
109
+ ## Fixed
110
+
111
+ - ...
112
+ ```
113
+
114
+ Only include `Added` / `Changed` / `Fixed` headings that have entries.
115
+
116
+ ## Notes for adopters
117
+
118
+ - **Single-package repos** leave `affectedPackages: false` (the `config.json`
119
+ default), which omits `affected_packages` and makes `set-affected-packages` a
120
+ no-op — there is only one package, so the field is write-only noise. The
121
+ validator treats `affected_packages` as optional-when-present, so leaving it out
122
+ is fine. As shipped, `validate` requires only `title`, `created_at`,
123
+ `category`, and `breaking`; `branch`, `author`, `co_authors`, and `stats` are
124
+ validated by type when present but are not required.
125
+ - **Adding a `version` field.** A single-versioned package may add `version` to
126
+ record the release each entry shipped in; that is owned by the release step,
127
+ alongside the other post-merge fields.
128
+ - The `issueKeys`, `linearWorkspaceSlug`, and `baseBranch` knobs in `config.json`
129
+ are the only repo-specific inputs; everything else in the contract is generic.
package/config.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "issueKeys": ["A"],
3
+ "linearWorkspaceSlug": "rheged-studio",
4
+ "baseBranch": "main",
5
+ "changelogDir": "changelog",
6
+ "packageRoots": ["apps", "packages", "services"],
7
+ "fallbackPackage": "infrastructure",
8
+ "affectedPackages": false
9
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ // Bin entry for @acme-skunkworks/changelog-core — parse the subcommand and
3
+ // dispatch to the matching command main. Global `--config <path>` (before or
4
+ // after the subcommand) is mapped to CHANGELOG_CONFIG so loadConfig() picks it
5
+ // up without each command re-parsing the flag.
6
+ import { main as addLinksMain } from "./commands/add-links.js";
7
+ import { main as backfillCommitsMain } from "./commands/backfill-commits.js";
8
+ import { main as checkCompletenessMain } from "./commands/check-completeness.js";
9
+ import { main as enrichMain } from "./commands/enrich.js";
10
+ import { main as finaliseMain } from "./commands/finalise.js";
11
+ import { main as setAffectedPackagesMain } from "./commands/set-affected-packages.js";
12
+ import { main as validateMain } from "./commands/validate.js";
13
+ import { createRequire } from "node:module";
14
+ import { argv, exit } from "node:process";
15
+ const { version: PACKAGE_VERSION } = createRequire(import.meta.url)("../package.json");
16
+ const SUBCOMMANDS = [
17
+ "validate",
18
+ "enrich",
19
+ "finalise",
20
+ "set-affected-packages",
21
+ "add-links",
22
+ "backfill-commits",
23
+ "check-completeness",
24
+ ];
25
+ const DISPATCH = {
26
+ "add-links": addLinksMain,
27
+ "backfill-commits": backfillCommitsMain,
28
+ "check-completeness": checkCompletenessMain,
29
+ enrich: enrichMain,
30
+ finalise: finaliseMain,
31
+ "set-affected-packages": setAffectedPackagesMain,
32
+ validate: validateMain,
33
+ };
34
+ const USAGE = `changelog-core — dated changelog tooling (validate, enrich, finalise, …)
35
+
36
+ Usage:
37
+ changelog-core [--config <path>] <subcommand> [args…]
38
+
39
+ Subcommands:
40
+ validate Validate dated changelog/ entries against the contract
41
+ enrich Fill post-merge fields of one entry (env-driven)
42
+ finalise Release-time enrich + version-stamp
43
+ set-affected-packages Write affected_packages from the branch diff
44
+ add-links Rewrite bare Linear IDs in bodies to links
45
+ backfill-commits One-off backfill of stats.commits
46
+ check-completeness Gate a release-triggering PR on a changelog entry
47
+
48
+ Global options:
49
+ --config <path> Path to config.json (also via CHANGELOG_CONFIG)
50
+ --help, -h Show this message
51
+ --version, -V Print the package version`;
52
+ function extractConfig(args) {
53
+ const rest = [];
54
+ let configPath;
55
+ for (let index = 0; index < args.length; index++) {
56
+ const argument = args[index];
57
+ if (argument === "--config") {
58
+ const next = args[++index];
59
+ if (!next || next.startsWith("-")) {
60
+ console.error("changelog-core: --config requires a path argument");
61
+ exit(2);
62
+ }
63
+ configPath = next;
64
+ }
65
+ else if (argument.startsWith("--config=")) {
66
+ const value = argument.slice("--config=".length);
67
+ if (!value) {
68
+ console.error("changelog-core: --config requires a path argument");
69
+ exit(2);
70
+ }
71
+ configPath = value;
72
+ }
73
+ else {
74
+ rest.push(argument);
75
+ }
76
+ }
77
+ return { configPath, rest };
78
+ }
79
+ function isSubcommand(value) {
80
+ return SUBCOMMANDS.includes(value);
81
+ }
82
+ function main() {
83
+ const { configPath, rest } = extractConfig(argv.slice(2));
84
+ if (rest.length === 0 || rest[0] === "--help" || rest[0] === "-h") {
85
+ console.log(USAGE);
86
+ return;
87
+ }
88
+ if (rest[0] === "--version" || rest[0] === "-V") {
89
+ console.log(PACKAGE_VERSION);
90
+ return;
91
+ }
92
+ const subcommand = rest[0];
93
+ if (!isSubcommand(subcommand)) {
94
+ console.error(`changelog-core: unknown subcommand '${subcommand}'\n`);
95
+ console.error(USAGE);
96
+ exit(2);
97
+ }
98
+ if (configPath) {
99
+ process.env.CHANGELOG_CONFIG = configPath;
100
+ }
101
+ // Leave the subcommand (and any remaining flags) on argv so command mains that
102
+ // scan argv.slice(2) for --help / --self-test / --check still see them. The
103
+ // subcommand name itself is ignored by those scanners.
104
+ DISPATCH[subcommand]();
105
+ }
106
+ main();
@@ -0,0 +1,8 @@
1
+ export declare function rewriteBody(body: string): string;
2
+ export declare function splitFrontmatter(raw: string): {
3
+ body: string;
4
+ fm: string;
5
+ };
6
+ export declare function frontmatterBranch(raw: string): null | string;
7
+ export declare function main(): void;
8
+ //# sourceMappingURL=add-links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"add-links.d.ts","sourceRoot":"","sources":["../../src/commands/add-links.ts"],"names":[],"mappings":"AA6BA,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CA8ChD;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAU1E;AAKD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAS5D;AAmHD,wBAAgB,IAAI,IAAI,IAAI,CA6E3B"}
@@ -0,0 +1,258 @@
1
+ import { isCliEntry } from "../lib/cli-entry.js";
2
+ import { loadConfig } from "../lib/config.js";
3
+ import { buildIssueRe } from "../lib/vendor/issue-keys.js";
4
+ import { spawnSync } from "node:child_process";
5
+ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { argv } from "node:process";
8
+ const FENCE_RE = /```[\s\S]*?```/g;
9
+ const INLINE_CODE_RE = /`[^`]*`/g;
10
+ const ALREADY_LINKED_RE = /\[[^\]]*\]\([^)]*\)/g;
11
+ // Reference-link definition lines — `[A-123]: <url>`, plus any indented
12
+ // continuation lines. The label here is the definition's key, not prose, so
13
+ // masking it stops `ISSUE_RE` rewriting it into `[[A-123](url)]: <url>` and
14
+ // breaking the reference. CommonMark allows up to three leading spaces before
15
+ // the label (four or more would be an indented code block), so allow `{0,3}`.
16
+ // Must run before `REFERENCE_LINKED_RE` so a definition is never partially
17
+ // consumed as an in-text label.
18
+ const REFERENCE_DEFINITION_RE = /^ {0,3}\[[^\]]+\]:[^\n]*(?:\n[ \t][^\n]*)*/gm;
19
+ // Reference-style links — `[text][ref]` and the collapsed `[text][]` — also
20
+ // already point at a definition, so mask them too. Without this, `ISSUE_RE`
21
+ // rewrites inside the label (`[A-1][1]` -> `[[A-1](url)][1]`) and re-runs
22
+ // compound the corruption.
23
+ const REFERENCE_LINKED_RE = /\[[^\]]*\]\[[^\]]*\]/g;
24
+ function buildUrl(workspace, id) {
25
+ return `https://linear.app/${workspace}/issue/${id}`;
26
+ }
27
+ export function rewriteBody(body) {
28
+ // Lazy config load so `--config` / CHANGELOG_CONFIG set by the CLI dispatcher
29
+ // (or tests via resetConfigCache) take effect before the first rewrite.
30
+ const { issueKeys: teamKeys, linearWorkspaceSlug: workspace } = loadConfig();
31
+ const issueRe = buildIssueRe(teamKeys);
32
+ if (!issueRe) {
33
+ return body;
34
+ }
35
+ // Mask fenced/inline code and existing links so issue-like text inside them
36
+ // isn't linkified. Tokens are delimited with NUL bytes, which cannot occur in
37
+ // a UTF-8 text file, so a token can never collide with real prose on restore
38
+ // (the previous bare `FENCE0`/`INLINE1`/`LINK2` tokens could).
39
+ const masks = [];
40
+ function mask(match) {
41
+ masks.push(match);
42
+ return `\u0000CR_MASK_${masks.length - 1}\u0000`;
43
+ }
44
+ const masked = body
45
+ .replaceAll(FENCE_RE, mask)
46
+ .replaceAll(INLINE_CODE_RE, mask)
47
+ .replaceAll(ALREADY_LINKED_RE, mask)
48
+ .replaceAll(REFERENCE_DEFINITION_RE, mask)
49
+ .replaceAll(REFERENCE_LINKED_RE, mask)
50
+ .replace(issueRe, (id) => `[${id}](${buildUrl(workspace, id)})`);
51
+ // Unmask is reentrant. Masks can nest: inline code inside a Markdown link —
52
+ // `` [`code`](url) `` — has the inner inline-code span masked first, then the
53
+ // whole `[<token>](url)` masked again, so the outer mask's stored value still
54
+ // contains the inner token. A single replace pass restores the outer token but
55
+ // leaves that inner `\0…\0` token embedded, so literal NUL bytes survive into
56
+ // the file (the entry becomes a binary blob). Restore to a fixed point instead.
57
+ // Each token is stored before any token that embeds it, so every pass strictly
58
+ // reduces the highest remaining index and the loop terminates.
59
+ let unmasked = masked;
60
+ let previous;
61
+ do {
62
+ previous = unmasked;
63
+ unmasked = unmasked.replaceAll(/\0CR_MASK_(\d+)\0/g, (_, index) => masks[Number(index)]);
64
+ } while (unmasked !== previous);
65
+ return unmasked;
66
+ }
67
+ export function splitFrontmatter(raw) {
68
+ // Match the opening/closing `---` fences with either LF or CRLF endings so a
69
+ // file authored on Windows isn't treated as having no frontmatter (which
70
+ // would let `rewriteBody` rewrite the frontmatter region too).
71
+ const match = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
72
+ if (!match) {
73
+ return { body: raw, fm: "" };
74
+ }
75
+ return { body: raw.slice(match[0].length), fm: match[0] };
76
+ }
77
+ // The `branch:` value from a changelog entry's frontmatter (quotes stripped), or
78
+ // `null` when the entry has no frontmatter or no `branch:` field. Used to scope
79
+ // the default write pass to the entry(ies) authored on the current branch.
80
+ export function frontmatterBranch(raw) {
81
+ const { fm } = splitFrontmatter(raw);
82
+ const match = fm.match(/^branch:(.*)$/m);
83
+ if (!match) {
84
+ return null;
85
+ }
86
+ const value = match[1].trim().replaceAll(/^['"]|['"]$/g, "");
87
+ return value || null;
88
+ }
89
+ // The current git branch, or `null` when git is unavailable or HEAD is detached
90
+ // — callers fall back to the full-directory sweep in that case.
91
+ function currentGitBranch() {
92
+ try {
93
+ const result = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
94
+ encoding: "utf8",
95
+ });
96
+ if (result.status !== 0) {
97
+ return null;
98
+ }
99
+ const branch = result.stdout.trim();
100
+ return branch && branch !== "HEAD" ? branch : null;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ const USAGE = `add-links — rewrite bare Linear issue IDs in changelog bodies to Linear URLs
107
+
108
+ Usage:
109
+ changelog-core add-links Linkify the current branch's entry/entries (writes)
110
+ changelog-core add-links --all Linkify every changelog/<ts>-<slug>.md body (writes)
111
+ changelog-core add-links --check Report which files would change across ALL entries; write nothing
112
+ changelog-core add-links --dry-run Alias for --check
113
+ changelog-core add-links --self-test Run the built-in offline smoke test
114
+ changelog-core add-links --help Show this message (alias: -h)
115
+
116
+ By default the write pass is scoped to the entry(ies) whose \`branch:\` frontmatter
117
+ matches the current git branch, so authoring a new entry never rewrites unrelated,
118
+ already-merged entries (A-603). Use --all for the historical full-directory sweep.
119
+ When git is unavailable the default falls back to the full sweep.
120
+
121
+ --check always scans every entry (it's a completeness gate) and exits 1 when a
122
+ rewrite is needed, 0 when nothing would change.`;
123
+ // Offline smoke test for the pure rewriteBody: linkify a sample body and check
124
+ // the masking rules (fenced/inline code and existing links are left alone).
125
+ function selfTest() {
126
+ const cases = [];
127
+ // Masking invariants hold whatever the config: an already-linked ID and an
128
+ // inline-code ID must survive a rewrite untouched.
129
+ const alreadyLinked = "See [A-1](https://example.test/A-1) for context.";
130
+ cases.push({
131
+ name: "an already-linked issue ID is left untouched",
132
+ ok: rewriteBody(alreadyLinked) === alreadyLinked,
133
+ });
134
+ const inlineCode = "The token `A-1` is code, not a link.";
135
+ cases.push({
136
+ name: "an issue ID inside inline code is not linkified",
137
+ ok: rewriteBody(inlineCode) === inlineCode,
138
+ });
139
+ const fenced = "```\nA-1 in a fence\n```";
140
+ cases.push({
141
+ name: "an issue ID inside a code fence is not linkified",
142
+ ok: rewriteBody(fenced) === fenced,
143
+ });
144
+ // Inline code nested inside a Markdown link masks twice (inner span, then the
145
+ // whole link). The reentrant unmask must restore both so no NUL token survives.
146
+ const codeInLink = "See [`A-1`](https://example.test/A-1) for the helper.";
147
+ const codeInLinkOut = rewriteBody(codeInLink);
148
+ cases.push({
149
+ name: "inline code nested inside a link round-trips with no NUL bytes",
150
+ ok: codeInLinkOut === codeInLink && !codeInLinkOut.includes("\u0000"),
151
+ });
152
+ // When the host config has issue keys, a bare ID for the first key linkifies.
153
+ // Wrap loadConfig so --self-test still passes in a fresh consumer cwd with no
154
+ // config.json yet (masking cases above already ran).
155
+ try {
156
+ const { issueKeys: teamKeys, linearWorkspaceSlug: workspace } = loadConfig();
157
+ const issueRe = buildIssueRe(teamKeys);
158
+ if (issueRe && teamKeys.length > 0) {
159
+ const key = teamKeys[0];
160
+ const id = `${key}-123`;
161
+ const before = `Closes ${id}.`;
162
+ const after = rewriteBody(before);
163
+ cases.push({
164
+ name: `a bare ${id} is rewritten to a Linear link`,
165
+ ok: after === `Closes [${id}](${buildUrl(workspace, id)}).`,
166
+ });
167
+ }
168
+ else {
169
+ cases.push({
170
+ name: "no issue keys configured — rewriteBody is a no-op",
171
+ ok: rewriteBody("Closes A-1.") === "Closes A-1.",
172
+ });
173
+ }
174
+ }
175
+ catch {
176
+ cases.push({
177
+ name: "no config available — masking cases already covered",
178
+ ok: true,
179
+ });
180
+ }
181
+ let failed = 0;
182
+ for (const { name, ok } of cases) {
183
+ if (ok) {
184
+ console.log(` ok ${name}`);
185
+ }
186
+ else {
187
+ failed += 1;
188
+ console.log(` FAIL ${name}`);
189
+ }
190
+ }
191
+ console.log(`\n${cases.length - failed}/${cases.length} passed`);
192
+ process.exit(failed === 0 ? 0 : 1);
193
+ }
194
+ export function main() {
195
+ const args = argv.slice(2);
196
+ if (args.includes("--help") || args.includes("-h")) {
197
+ console.log(USAGE);
198
+ return;
199
+ }
200
+ if (args.includes("--self-test")) {
201
+ selfTest();
202
+ return;
203
+ }
204
+ // --check (alias --dry-run): report which files would be rewritten and write
205
+ // nothing. Exit 0 when nothing would change, 1 when a rewrite is needed —
206
+ // prettier-style, so CI can gate on it.
207
+ const check = args.some((argument) => argument === "--check" || argument === "--dry-run");
208
+ const all = args.includes("--all");
209
+ const { changelogDir } = loadConfig();
210
+ let stat;
211
+ try {
212
+ stat = statSync(changelogDir);
213
+ }
214
+ catch {
215
+ console.error(`changelog directory not found: ${changelogDir}`);
216
+ process.exit(2);
217
+ }
218
+ if (!stat.isDirectory()) {
219
+ console.error(`${changelogDir} is not a directory`);
220
+ process.exit(2);
221
+ }
222
+ let files = readdirSync(changelogDir)
223
+ .filter((name) => name.endsWith(".md") && name !== "README.md")
224
+ .map((name) => join(changelogDir, name));
225
+ // Default write pass: scope to the entry(ies) authored on the current branch
226
+ // (matched by `branch:` frontmatter), so linkifying a new entry never churns
227
+ // unrelated, already-merged entries (A-603). `--all` and `--check` keep the
228
+ // full-directory scan; a non-git context (no branch) also falls back to it.
229
+ if (!all && !check) {
230
+ const branch = currentGitBranch();
231
+ if (branch) {
232
+ files = files.filter((file) => frontmatterBranch(readFileSync(file, "utf8")) === branch);
233
+ }
234
+ }
235
+ let touched = 0;
236
+ for (const file of files) {
237
+ const raw = readFileSync(file, "utf8");
238
+ const { body, fm } = splitFrontmatter(raw);
239
+ const next = rewriteBody(body);
240
+ if (next !== body) {
241
+ if (!check) {
242
+ writeFileSync(file, fm + next);
243
+ }
244
+ touched++;
245
+ console.log(check ? `[check] would rewrite: ${file}` : `rewrote: ${file}`);
246
+ }
247
+ }
248
+ if (check) {
249
+ console.log(`[check] Linear link rewriting: ${touched} file(s) would be updated.`);
250
+ process.exit(touched > 0 ? 1 : 0);
251
+ }
252
+ console.log(`Linear link rewriting complete. ${touched} file(s) updated.`);
253
+ }
254
+ // Only run the filesystem pass when invoked as a CLI, not when imported (e.g.
255
+ // by unit tests exercising `rewriteBody`).
256
+ if (isCliEntry(import.meta.filename)) {
257
+ main();
258
+ }
@@ -0,0 +1,19 @@
1
+ import type { Runner } from "../lib/commit-count.js";
2
+ import type { FrontmatterData } from "../lib/frontmatter.js";
3
+ /**
4
+ * Resolve the merged PR number for an entry: prefer the recorded `pr` field,
5
+ * else look it up from the `branch`. Returns the number, or null when there's no
6
+ * resolvable merged PR (e.g. an entry whose PR never merged or predates GitHub).
7
+ * Throws (rather than returning null) if the gh output isn't valid JSON —
8
+ * main()'s per-entry try/catch owns that, so a malformed response skips the
9
+ * entry loudly instead of being mistaken for "no PR".
10
+ */
11
+ export declare function resolvePrNumber(run: Runner, fm: FrontmatterData): null | number;
12
+ /**
13
+ * Pure backfill of a single entry's raw markdown. Returns the rewritten markdown
14
+ * with `stats.commits` set, or null when nothing changed (unresolvable PR, or
15
+ * the count already matches).
16
+ */
17
+ export declare function backfillEntry(raw: string, run: Runner): null | string;
18
+ export declare function main(): void;
19
+ //# sourceMappingURL=backfill-commits.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backfill-commits.d.ts","sourceRoot":"","sources":["../../src/commands/backfill-commits.ts"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAGrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAK7D;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,MAAM,EACX,EAAE,EAAE,eAAe,GAClB,IAAI,GAAG,MAAM,CA8Bf;AAsGD;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAyBrE;AAaD,wBAAgB,IAAI,IAAI,IAAI,CA+C3B"}