@acme-skunkworks/eslint-config 1.0.2 → 1.0.4

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 (52) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +5 -3
  3. package/dist/infrastructure/scripts/add-links-changelog.d.ts +12 -0
  4. package/dist/infrastructure/scripts/add-links-changelog.d.ts.map +1 -0
  5. package/dist/infrastructure/scripts/add-links-changelog.js +56 -0
  6. package/dist/infrastructure/scripts/enrich-changelog.d.ts +26 -0
  7. package/dist/infrastructure/scripts/enrich-changelog.d.ts.map +1 -0
  8. package/dist/infrastructure/scripts/enrich-changelog.js +60 -0
  9. package/dist/infrastructure/scripts/finalise-changelog.d.ts +26 -0
  10. package/dist/infrastructure/scripts/finalise-changelog.d.ts.map +1 -0
  11. package/dist/infrastructure/scripts/finalise-changelog.js +152 -0
  12. package/dist/infrastructure/scripts/stamp-changelog-version.d.ts +10 -0
  13. package/dist/infrastructure/scripts/stamp-changelog-version.d.ts.map +1 -0
  14. package/dist/infrastructure/scripts/stamp-changelog-version.js +35 -0
  15. package/dist/infrastructure/scripts/validate-changelog.d.ts +7 -0
  16. package/dist/infrastructure/scripts/validate-changelog.d.ts.map +1 -0
  17. package/dist/infrastructure/scripts/validate-changelog.js +216 -0
  18. package/dist/rules/astro.d.ts.map +1 -1
  19. package/dist/rules/astro.js +9 -6
  20. package/dist/rules/commonjs.d.ts +2 -1
  21. package/dist/rules/commonjs.d.ts.map +1 -1
  22. package/dist/rules/commonjs.js +2 -1
  23. package/dist/rules/complexity.d.ts.map +1 -1
  24. package/dist/rules/complexity.js +3 -0
  25. package/dist/rules/e2e.d.ts.map +1 -1
  26. package/dist/rules/e2e.js +3 -0
  27. package/dist/rules/frameworkRouting.d.ts.map +1 -1
  28. package/dist/rules/frameworkRouting.js +4 -3
  29. package/dist/rules/ignoredFileAndFolders.d.ts +1 -0
  30. package/dist/rules/ignoredFileAndFolders.d.ts.map +1 -1
  31. package/dist/rules/ignoredFileAndFolders.js +1 -0
  32. package/dist/rules/packageJson.d.ts.map +1 -1
  33. package/dist/rules/packageJson.js +3 -0
  34. package/dist/rules/preferences.d.ts.map +1 -1
  35. package/dist/rules/preferences.js +67 -29
  36. package/dist/rules/reactRouterExceptions.d.ts.map +1 -1
  37. package/dist/rules/reactRouterExceptions.js +3 -14
  38. package/dist/rules/sanity.d.ts +1 -1
  39. package/dist/rules/sanity.d.ts.map +1 -1
  40. package/dist/rules/sanity.js +18 -7
  41. package/dist/rules/storybook.d.ts.map +1 -1
  42. package/dist/rules/storybook.js +6 -2
  43. package/dist/rules/tableComponents.d.ts.map +1 -1
  44. package/dist/rules/tableComponents.js +3 -0
  45. package/dist/rules/testFiles.d.ts.map +1 -1
  46. package/dist/rules/testFiles.js +12 -10
  47. package/dist/rules/typescriptOverrides.d.ts.map +1 -1
  48. package/dist/rules/typescriptOverrides.js +6 -0
  49. package/package.json +12 -10
  50. package/dist/infrastructure/scripts/retitle-release-pr.d.ts +0 -11
  51. package/dist/infrastructure/scripts/retitle-release-pr.d.ts.map +0 -1
  52. package/dist/infrastructure/scripts/retitle-release-pr.js +0 -50
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAerC;;;;;GAKG;AACH,eAAO,MAAM,IAAI,EAAE,MAAM,CAAC,MAAM,EAO/B,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,UAAU,EAAE,MAAM,CAAC,MAA4B,CAAC;AAE7D;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAG3C,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,OAAO,EAAE,MAAM,CAAC,MAAkB,CAAC;AAEhD;;GAEG;AACH,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AACzC;;GAEG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AACnD;;GAEG;AACH,OAAO,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAC;AACrC;;GAEG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAC3C;;GAEG;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD;;GAEG;AACH,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAE7D;;;;;;;;GAQG;AACH,QAAA,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EASjC,CAAC;AAEF,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAerC;;;;;GAKG;AACH,eAAO,MAAM,IAAI,EAAE,MAAM,CAAC,MAAM,EAS/B,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,UAAU,EAAE,MAAM,CAAC,MAA4B,CAAC;AAE7D;;;;;;;;GAQG;AACH,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAG3C,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,OAAO,EAAE,MAAM,CAAC,MAAkB,CAAC;AAEhD;;GAEG;AACH,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AACzC;;GAEG;AACH,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AACnD;;GAEG;AACH,OAAO,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAC;AACrC;;GAEG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAC3C;;GAEG;AACH,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AACjD;;GAEG;AACH,OAAO,EAAE,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAE7D;;;;;;;;GAQG;AACH,QAAA,MAAM,aAAa,EAAE,MAAM,CAAC,MAAM,EASjC,CAAC;AAEF,eAAe,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -13,10 +13,10 @@ import { testFiles } from "./rules/testFiles.js";
13
13
  import { typescriptOverrides } from "./rules/typescriptOverrides.js";
14
14
  import eslintConfigCanonicalAuto from "eslint-config-canonical/auto";
15
15
  import pluginImportX from "eslint-plugin-import-x";
16
- // Plugin aliasing for eslint-config-canonical compatibility.
17
- // Canonical references "import" but the package is "eslint-plugin-import-x".
18
- // Register under both names so canonical's rules resolve and modern code works.
16
+ // Plugin aliasing registers eslint-plugin-import-x under both `import` and `import-x`.
17
+ // Canonical's config references the `import` plugin name; we ship import-x. Both names must resolve.
19
18
  // See: https://github.com/RobEasthope/protomolecule/issues/259
19
+ // https://github.com/un-ts/eslint-plugin-import-x
20
20
  const importXAlias = {
21
21
  plugins: {
22
22
  import: pluginImportX,
@@ -32,6 +32,8 @@ const importXAlias = {
32
32
  export const base = [
33
33
  importXAlias,
34
34
  ignoredFileAndFolders,
35
+ // eslint-config-canonical/auto — upstream baseline (most rules live here, not in rules/*.ts).
36
+ // Per-rule inventory: https://github.com/gajus/eslint-config-canonical
35
37
  ...eslintConfigCanonicalAuto,
36
38
  packageJson,
37
39
  commonjs,
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Rewrite bare Linear IDs in a markdown body to links, masking code/links.
3
+ */
4
+ export declare function rewriteBody(body: string): string;
5
+ /**
6
+ * Split leading YAML frontmatter from the body, preserving the fence bytes.
7
+ */
8
+ export declare function splitFrontmatter(raw: string): {
9
+ body: string;
10
+ fm: string;
11
+ };
12
+ //# sourceMappingURL=add-links-changelog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"add-links-changelog.d.ts","sourceRoot":"","sources":["../../../infrastructure/scripts/add-links-changelog.ts"],"names":[],"mappings":"AAwBA;;GAEG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAgBhD;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAe1E"}
@@ -0,0 +1,56 @@
1
+ // Pure helper: rewrite bare Linear issue IDs (e.g. ASW-123) in changelog entry
2
+ // bodies into markdown links, masking code fences / inline code / already-linked
3
+ // IDs first so they're left untouched. Ported from octavo's add-links.mjs,
4
+ // adapted to this workspace (acme-skunkworks).
5
+ //
6
+ // Library module (no CLI): the release-time orchestrator finalise-changelog.ts
7
+ // applies it. Kept pure so it's trivially unit-testable.
8
+ const WORKSPACE = "acme-skunkworks";
9
+ const TEAM_KEYS = ["ASW", "AKW"];
10
+ const ISSUE_RE = new RegExp(`\\b(?:${TEAM_KEYS.join("|")})-\\d+\\b`, "g");
11
+ const FENCE_RE = /```[\s\S]*?```/g;
12
+ const INLINE_CODE_RE = /`[^`]*`/g;
13
+ const ALREADY_LINKED_RE = /\[[^\]]*\]\([^)]*\)/g;
14
+ // Private-Use-Area sentinel wrapping each masked span, so a placeholder can
15
+ // never collide with literal body text (e.g. a code sample containing the
16
+ // string "FENCE0"). U+E000 cannot appear in a markdown source file.
17
+ const SENTINEL = "\u{E000}";
18
+ const PLACEHOLDER_RE = /\u{E000}(?:FENCE|INLINE|LINK)(\d+)\u{E000}/gu;
19
+ function buildUrl(id) {
20
+ return `https://linear.app/${WORKSPACE}/issue/${id}`;
21
+ }
22
+ /**
23
+ * Rewrite bare Linear IDs in a markdown body to links, masking code/links.
24
+ */
25
+ export function rewriteBody(body) {
26
+ const masks = [];
27
+ function mask(label) {
28
+ return (matched) => {
29
+ masks.push(matched);
30
+ return `${SENTINEL}${label}${masks.length - 1}${SENTINEL}`;
31
+ };
32
+ }
33
+ const masked = body
34
+ .replaceAll(FENCE_RE, mask("FENCE"))
35
+ .replaceAll(INLINE_CODE_RE, mask("INLINE"))
36
+ .replaceAll(ALREADY_LINKED_RE, mask("LINK"))
37
+ .replaceAll(ISSUE_RE, (id) => `[${id}](${buildUrl(id)})`);
38
+ return masked.replaceAll(PLACEHOLDER_RE, (_, index) => masks[Number(index)]);
39
+ }
40
+ /**
41
+ * Split leading YAML frontmatter from the body, preserving the fence bytes.
42
+ */
43
+ export function splitFrontmatter(raw) {
44
+ if (!raw.startsWith("---\n")) {
45
+ return { body: raw, fm: "" };
46
+ }
47
+ // Search from index 3: the opening "---\n" is exactly 4 bytes, so the
48
+ // earliest a closing "\n---\n" can start is index 3 (the newline ending the
49
+ // opening fence). Starting at 4 would miss the close of an empty frontmatter
50
+ // ("---\n---\n").
51
+ const end = raw.indexOf("\n---\n", 3);
52
+ if (end === -1) {
53
+ return { body: raw, fm: "" };
54
+ }
55
+ return { body: raw.slice(end + 5), fm: raw.slice(0, end + 5) };
56
+ }
@@ -0,0 +1,26 @@
1
+ export type EnrichInput = {
2
+ additions?: null | string;
3
+ /**
4
+ * Feature branch name — the stable lookup key.
5
+ */
6
+ branch: string;
7
+ changedFiles?: null | string;
8
+ deletions?: null | string;
9
+ /**
10
+ * PR merged_at timestamp (ISO 8601 UTC).
11
+ */
12
+ mergedAt: string;
13
+ /**
14
+ * Merge commit SHA (full or short); only the first 7 chars are stored.
15
+ */
16
+ mergeSha: string;
17
+ mergeStrategy?: null | string;
18
+ prNumber?: null | string;
19
+ };
20
+ /**
21
+ * Apply enrichment to a single entry's raw markdown and return the rewritten
22
+ * markdown. Fill-once for merged_at/commit/merge_strategy/pr; authoritative
23
+ * overwrite for stats. created_at is never touched.
24
+ */
25
+ export declare function enrichFrontmatter(raw: string, input: EnrichInput): string;
26
+ //# sourceMappingURL=enrich-changelog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"enrich-changelog.d.ts","sourceRoot":"","sources":["../../../infrastructure/scripts/enrich-changelog.ts"],"names":[],"mappings":"AAYA,MAAM,MAAM,WAAW,GAAG;IACxB,SAAS,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;IAC1B;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;IAC7B,SAAS,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;IAC1B;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB;;OAEG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;IAC9B,QAAQ,CAAC,EAAE,IAAI,GAAG,MAAM,CAAC;CAC1B,CAAC;AASF;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,MAAM,CAkDzE"}
@@ -0,0 +1,60 @@
1
+ // Pure enrichment of a changelog entry's frontmatter — fills the fields that
2
+ // are only knowable once the PR has merged (merged_at / commit / merge_strategy
3
+ // / pr) plus authoritative stats. `version` is filled separately by
4
+ // stamp-changelog-version. created_at is never touched.
5
+ //
6
+ // This is a library module (no CLI): the release-time orchestrator
7
+ // finalise-changelog.ts composes it with the PR data it resolves from `gh`.
8
+ // Ported from octavo's enrich-changelog.mjs, minus affected_packages (single
9
+ // package). Kept pure so it's trivially unit-testable.
10
+ import matter from "gray-matter";
11
+ /**
12
+ * True when a value is unset (null/undefined/"").
13
+ */
14
+ function blank(value) {
15
+ return value === null || value === undefined || value === "";
16
+ }
17
+ /**
18
+ * Apply enrichment to a single entry's raw markdown and return the rewritten
19
+ * markdown. Fill-once for merged_at/commit/merge_strategy/pr; authoritative
20
+ * overwrite for stats. created_at is never touched.
21
+ */
22
+ export function enrichFrontmatter(raw, input) {
23
+ const parsed = matter(raw);
24
+ const fm = { ...parsed.data };
25
+ if (!fm.created_at) {
26
+ throw new Error("entry has no created_at; refusing to enrich");
27
+ }
28
+ const shortSha = input.mergeSha.slice(0, 7);
29
+ if (blank(fm.merged_at)) {
30
+ fm.merged_at = input.mergedAt;
31
+ }
32
+ if (blank(fm.commit)) {
33
+ fm.commit = shortSha;
34
+ }
35
+ if (blank(fm.merge_strategy) && input.mergeStrategy) {
36
+ fm.merge_strategy = input.mergeStrategy;
37
+ }
38
+ if (blank(fm.pr) && input.prNumber) {
39
+ fm.pr = Number.parseInt(input.prNumber, 10);
40
+ }
41
+ // Authoritative overwrites from the GH API, always under stats: { ... }.
42
+ const stats = typeof fm.stats === "object" &&
43
+ fm.stats !== null &&
44
+ !Array.isArray(fm.stats)
45
+ ? { ...fm.stats }
46
+ : {};
47
+ // Guard with blank() (not just null/undefined): an empty string would slip
48
+ // through and Number.parseInt("", 10) is NaN, which the validator rejects.
49
+ if (!blank(input.additions)) {
50
+ stats.loc_added = Number.parseInt(input.additions, 10);
51
+ }
52
+ if (!blank(input.deletions)) {
53
+ stats.loc_removed = Number.parseInt(input.deletions, 10);
54
+ }
55
+ if (!blank(input.changedFiles)) {
56
+ stats.files_changed = Number.parseInt(input.changedFiles, 10);
57
+ }
58
+ fm.stats = stats;
59
+ return matter.stringify(parsed.content, fm);
60
+ }
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ export declare const CHANGELOG_DIR = "changelog";
3
+ export type ResolvedPr = {
4
+ additions: null | string;
5
+ changedFiles: null | string;
6
+ deletions: null | string;
7
+ mergedAt: string;
8
+ mergeSha: string;
9
+ mergeStrategy: null | string;
10
+ prNumber: string;
11
+ };
12
+ /**
13
+ * Resolve the merged PR for a branch, or null when none is found.
14
+ */
15
+ export type PrResolver = (branch: string) => null | ResolvedPr;
16
+ export type Runner = (cmd: string, args: readonly string[]) => string;
17
+ /**
18
+ * Finalise one entry's raw markdown for release. Returns the rewritten markdown,
19
+ * or null when nothing changed (already finalised).
20
+ */
21
+ export declare function finaliseEntry(raw: string, version: string, resolvePr: PrResolver): null | string;
22
+ /**
23
+ * Build a PR resolver backed by `gh` + `git` (injectable runner for tests).
24
+ */
25
+ export declare function makeResolver(run: Runner): PrResolver;
26
+ //# sourceMappingURL=finalise-changelog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"finalise-changelog.d.ts","sourceRoot":"","sources":["../../../infrastructure/scripts/finalise-changelog.ts"],"names":[],"mappings":";AAuBA,eAAO,MAAM,aAAa,cAAc,CAAC;AAEzC,MAAM,MAAM,UAAU,GAAG;IACvB,SAAS,EAAE,IAAI,GAAG,MAAM,CAAC;IACzB,YAAY,EAAE,IAAI,GAAG,MAAM,CAAC;IAC5B,SAAS,EAAE,IAAI,GAAG,MAAM,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,IAAI,GAAG,MAAM,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,GAAG,UAAU,CAAC;AAE/D,MAAM,MAAM,MAAM,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,KAAK,MAAM,CAAC;AAMtE;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,UAAU,GACpB,IAAI,GAAG,MAAM,CAgCf;AAaD;;GAEG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CA4EpD"}
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ // Release-time finalisation of changelog entries — run by changesets/action's
3
+ // `version:` command, right after `changeset version`, so the result is
4
+ // committed into the "release: version packages" PR (no separate workflow, no
5
+ // bot push to main).
6
+ //
7
+ // For every entry that isn't finalised yet (empty `version`):
8
+ // 1. resolve its merged PR from the `branch` field via `gh` and enrich
9
+ // (merged_at / commit / pr / merge_strategy / stats);
10
+ // 2. stamp `version` with the just-bumped package.json version;
11
+ // 3. rewrite bare Linear IDs to links.
12
+ //
13
+ // The pure `finaliseEntry(raw, version, resolvePr)` is unit-testable with a fake
14
+ // resolver; main() wires the real `gh`/`git` resolver and walks the directory.
15
+ import { rewriteBody, splitFrontmatter } from "./add-links-changelog.js";
16
+ import { enrichFrontmatter } from "./enrich-changelog.js";
17
+ import { readPackageVersion, stampVersion } from "./stamp-changelog-version.js";
18
+ import matter from "gray-matter";
19
+ import { execFileSync } from "node:child_process";
20
+ import { readdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ export const CHANGELOG_DIR = "changelog";
23
+ function blank(value) {
24
+ return value === null || value === undefined || value === "";
25
+ }
26
+ /**
27
+ * Finalise one entry's raw markdown for release. Returns the rewritten markdown,
28
+ * or null when nothing changed (already finalised).
29
+ */
30
+ export function finaliseEntry(raw, version, resolvePr) {
31
+ const fm = matter(raw).data;
32
+ if (!blank(fm.version)) {
33
+ return null; // already shipped in a release
34
+ }
35
+ let next = raw;
36
+ const branch = typeof fm.branch === "string" ? fm.branch : "";
37
+ const needsEnrich = blank(fm.merged_at) || blank(fm.commit) || blank(fm.pr);
38
+ if (branch && needsEnrich) {
39
+ const pr = resolvePr(branch);
40
+ if (pr) {
41
+ next = enrichFrontmatter(next, {
42
+ additions: pr.additions,
43
+ branch,
44
+ changedFiles: pr.changedFiles,
45
+ deletions: pr.deletions,
46
+ mergedAt: pr.mergedAt,
47
+ mergeSha: pr.mergeSha,
48
+ mergeStrategy: pr.mergeStrategy,
49
+ prNumber: pr.prNumber,
50
+ });
51
+ }
52
+ }
53
+ next = stampVersion(next, version) ?? next;
54
+ const { body, fm: fmText } = splitFrontmatter(next);
55
+ next = fmText + rewriteBody(body);
56
+ return next === raw ? null : next;
57
+ }
58
+ function realRunner(cmd, args) {
59
+ return execFileSync(cmd, args, {
60
+ encoding: "utf8",
61
+ stdio: ["ignore", "pipe", "inherit"],
62
+ // Fail fast if gh/git stalls (network/auth). Enrichment is best-effort, so
63
+ // a timeout throws → makeResolver's try/catch falls back to null rather
64
+ // than hanging the release until the whole job times out.
65
+ timeout: 30_000,
66
+ });
67
+ }
68
+ /**
69
+ * Build a PR resolver backed by `gh` + `git` (injectable runner for tests).
70
+ */
71
+ export function makeResolver(run) {
72
+ function resolve(branch) {
73
+ const json = run("gh", [
74
+ "pr",
75
+ "list",
76
+ "--head",
77
+ branch,
78
+ "--state",
79
+ "merged",
80
+ "--limit",
81
+ "1",
82
+ "--json",
83
+ "number,mergedAt,additions,deletions,changedFiles,mergeCommit,headRefOid",
84
+ ]);
85
+ const list = JSON.parse(json);
86
+ if (list.length === 0) {
87
+ return null;
88
+ }
89
+ const pr = list[0];
90
+ const mergeSha = pr.mergeCommit?.oid ?? "";
91
+ // Infer merge strategy from the merge commit shape (GitHub doesn't expose
92
+ // it directly): 2+ parents -> merge; otherwise squash.
93
+ // NOTE: rebase merges are also reported as "squash" — GitHub replays them
94
+ // with fresh SHAs, so mergeCommit.oid never equals headRefOid and the
95
+ // "rebase" branch below is effectively unreachable. This repo squash-merges
96
+ // anyway, and merge_strategy is only record-keeping metadata, so the
97
+ // imprecision is harmless.
98
+ let mergeStrategy = null;
99
+ if (mergeSha) {
100
+ const parents = (run("git", ["cat-file", "-p", mergeSha]).match(/^parent /gm) ?? []).length;
101
+ if (parents >= 2) {
102
+ mergeStrategy = "merge";
103
+ }
104
+ else {
105
+ mergeStrategy = mergeSha === pr.headRefOid ? "rebase" : "squash";
106
+ }
107
+ }
108
+ // Absent numeric fields stay null (not ""), so the enrich guard skips them
109
+ // rather than parsing "" into NaN.
110
+ return {
111
+ additions: pr.additions === undefined ? null : String(pr.additions),
112
+ changedFiles: pr.changedFiles === undefined ? null : String(pr.changedFiles),
113
+ deletions: pr.deletions === undefined ? null : String(pr.deletions),
114
+ mergedAt: pr.mergedAt ?? "",
115
+ mergeSha,
116
+ mergeStrategy,
117
+ prNumber: String(pr.number ?? ""),
118
+ };
119
+ }
120
+ return (branch) => {
121
+ // Enrichment is best-effort metadata: a gh/git failure here must NOT abort
122
+ // `changeset version` and block the release. On any error, warn and return
123
+ // null — the entry still gets version-stamped, just without PR metadata.
124
+ try {
125
+ return resolve(branch);
126
+ }
127
+ catch (error) {
128
+ console.warn(`⚠️ Could not resolve PR for branch ${branch}: ${error.message}`);
129
+ return null;
130
+ }
131
+ };
132
+ }
133
+ function main() {
134
+ const version = readPackageVersion(readFileSync("package.json", "utf8"));
135
+ const resolvePr = makeResolver(realRunner);
136
+ const files = readdirSync(CHANGELOG_DIR)
137
+ .filter((name) => name.endsWith(".md") && name !== "README.md")
138
+ .map((name) => join(CHANGELOG_DIR, name));
139
+ let finalised = 0;
140
+ for (const file of files) {
141
+ const next = finaliseEntry(readFileSync(file, "utf8"), version, resolvePr);
142
+ if (next !== null) {
143
+ writeFileSync(file, next);
144
+ finalised++;
145
+ console.log(`finalised ${version}: ${file}`);
146
+ }
147
+ }
148
+ console.log(`Changelog finalisation complete. ${finalised} entr${finalised === 1 ? "y" : "ies"} finalised with ${version}.`);
149
+ }
150
+ if (import.meta.url === `file://${process.argv[1]}`) {
151
+ main();
152
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Stamp `version` onto an entry if it has none. Returns the rewritten markdown,
3
+ * or null when the entry already has a version (no write needed).
4
+ */
5
+ export declare function stampVersion(raw: string, version: string): null | string;
6
+ /**
7
+ * Read the `version` field from a package.json string.
8
+ */
9
+ export declare function readPackageVersion(packageJsonRaw: string): string;
10
+ //# sourceMappingURL=stamp-changelog-version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stamp-changelog-version.d.ts","sourceRoot":"","sources":["../../../infrastructure/scripts/stamp-changelog-version.ts"],"names":[],"mappings":"AAeA;;;GAGG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CASxE;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,cAAc,EAAE,MAAM,GAAG,MAAM,CAOjE"}
@@ -0,0 +1,35 @@
1
+ // Pure helpers for release-time version stamping: set `version` on an entry
2
+ // that doesn't have one, and read the version from package.json.
3
+ //
4
+ // Library module (no CLI): the release-time orchestrator finalise-changelog.ts
5
+ // composes these. Kept pure so they're trivially unit-testable.
6
+ import matter from "gray-matter";
7
+ /**
8
+ * True when a value is unset (null/undefined/"").
9
+ */
10
+ function blank(value) {
11
+ return value === null || value === undefined || value === "";
12
+ }
13
+ /**
14
+ * Stamp `version` onto an entry if it has none. Returns the rewritten markdown,
15
+ * or null when the entry already has a version (no write needed).
16
+ */
17
+ export function stampVersion(raw, version) {
18
+ const parsed = matter(raw);
19
+ const fm = { ...parsed.data };
20
+ if (!blank(fm.version)) {
21
+ return null;
22
+ }
23
+ fm.version = version;
24
+ return matter.stringify(parsed.content, fm);
25
+ }
26
+ /**
27
+ * Read the `version` field from a package.json string.
28
+ */
29
+ export function readPackageVersion(packageJsonRaw) {
30
+ const pkg = JSON.parse(packageJsonRaw);
31
+ if (typeof pkg.version !== "string" || pkg.version.length === 0) {
32
+ throw new Error("package.json is missing a string `version`");
33
+ }
34
+ return pkg.version;
35
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ export declare const CHANGELOG_DIR = "changelog";
3
+ /**
4
+ * Validate one entry. Returns an array of human-readable error strings.
5
+ */
6
+ export declare function validateEntry(name: string, raw: string): string[];
7
+ //# sourceMappingURL=validate-changelog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-changelog.d.ts","sourceRoot":"","sources":["../../../infrastructure/scripts/validate-changelog.ts"],"names":[],"mappings":";AAoBA,eAAO,MAAM,aAAa,cAAc,CAAC;AA0DzC;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CA0JjE"}
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env -S npx tsx
2
+ // Validates the individual dated changelog entries under `changelog/`.
3
+ //
4
+ // Ported from octavo's scripts/validate-changelog.mjs and adapted for this
5
+ // repo (single, semver'd npm package):
6
+ // - `version` is accepted (typed-when-present semver string); octavo has none.
7
+ // - `affected_packages` is dropped (one package, not a monorepo).
8
+ // - the REQUIRED set is relaxed to title/created_at/category/breaking so that
9
+ // both backfilled historical entries (no branch/author/stats) and in-flight
10
+ // entries (no version/merged_at/pr/commit/stats until enriched) validate.
11
+ // /send-it is the guarantee that new entries get branch/author/co_authors;
12
+ // validation is the safety net, not the sole guard.
13
+ //
14
+ // The pure `validateEntry(name, raw)` returns an array of error strings (empty
15
+ // means valid), so it's trivially unit-testable; main() walks the directory.
16
+ import matter from "gray-matter";
17
+ import { readdirSync, readFileSync, statSync } from "node:fs";
18
+ import { basename, join } from "node:path";
19
+ export const CHANGELOG_DIR = "changelog";
20
+ const FILENAME_RE = /^(\d{8})-(\d{6})-([a-z0-9-]+)\.md$/;
21
+ const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
22
+ // SemVer 2.0.0: prerelease and build identifiers are dot-separated and may
23
+ // contain ASCII alphanumerics and hyphens (e.g. 1.2.3-rc-1, 1.2.3+build-45).
24
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
25
+ const SHA7_RE = /^[0-9a-f]{7}$/;
26
+ const ISSUE_RE = /^[A-Z]{2,}-\d+$/;
27
+ const CATEGORIES = new Set([
28
+ "chore",
29
+ "docs",
30
+ "feature",
31
+ "fix",
32
+ "perf",
33
+ "refactor",
34
+ ]);
35
+ const MERGE_STRATEGIES = new Set(["merge", "rebase", "squash"]);
36
+ const SECTION_RE = /^##\s+(Breaking|Added|Changed|Fixed)\b/m;
37
+ const REQUIRED = ["title", "created_at", "category", "breaking"];
38
+ /**
39
+ * True when a value is set to something meaningful (not null/undefined/"").
40
+ */
41
+ function present(value) {
42
+ return value !== null && value !== undefined && value !== "";
43
+ }
44
+ function isInt(value) {
45
+ return typeof value === "number" && Number.isInteger(value);
46
+ }
47
+ function isNonNegInt(value) {
48
+ return isInt(value) && value >= 0;
49
+ }
50
+ function isStringArray(value) {
51
+ return (Array.isArray(value) && value.every((item) => typeof item === "string"));
52
+ }
53
+ function asIso(value) {
54
+ if (typeof value === "string") {
55
+ return value;
56
+ }
57
+ if (value instanceof Date) {
58
+ return value.toISOString();
59
+ }
60
+ return "";
61
+ }
62
+ /**
63
+ * Validate one entry. Returns an array of human-readable error strings.
64
+ */
65
+ export function validateEntry(name, raw) {
66
+ const errors = [];
67
+ function fail(message) {
68
+ errors.push(`${name}: ${message}`);
69
+ }
70
+ if (!FILENAME_RE.test(name)) {
71
+ fail("filename must match YYYYMMDD-HHMMSS-<slug>.md (slug: [a-z0-9-]+)");
72
+ return errors;
73
+ }
74
+ let parsed;
75
+ try {
76
+ parsed = matter(raw);
77
+ }
78
+ catch (error) {
79
+ fail(`frontmatter unparseable: ${error.message}`);
80
+ return errors;
81
+ }
82
+ const fm = (parsed.data ?? {});
83
+ const body = parsed.content ?? "";
84
+ for (const key of REQUIRED) {
85
+ if (!(key in fm)) {
86
+ fail(`missing required field: ${key}`);
87
+ }
88
+ }
89
+ if ("title" in fm &&
90
+ (typeof fm.title !== "string" || fm.title.trim() === "")) {
91
+ fail("title must be a non-empty string");
92
+ }
93
+ if ("release_note" in fm &&
94
+ fm.release_note !== null &&
95
+ typeof fm.release_note !== "string") {
96
+ fail("release_note must be a string or null when present");
97
+ }
98
+ if (present(fm.version) &&
99
+ (typeof fm.version !== "string" || !SEMVER_RE.test(fm.version))) {
100
+ fail(`version must be a semver string when set (got ${JSON.stringify(fm.version)})`);
101
+ }
102
+ if ("created_at" in fm && !ISO_UTC_RE.test(asIso(fm.created_at))) {
103
+ fail(`created_at must be ISO 8601 UTC with Z suffix (got ${JSON.stringify(fm.created_at)})`);
104
+ }
105
+ if (present(fm.merged_at) && !ISO_UTC_RE.test(asIso(fm.merged_at))) {
106
+ fail("merged_at must be ISO 8601 UTC with Z suffix when set");
107
+ }
108
+ if ("branch" in fm &&
109
+ (typeof fm.branch !== "string" || fm.branch.trim() === "")) {
110
+ fail("branch must be a non-empty string when present");
111
+ }
112
+ if (present(fm.pr) && !isInt(fm.pr)) {
113
+ fail("pr must be an integer when set");
114
+ }
115
+ if (present(fm.commit) && !SHA7_RE.test(String(fm.commit))) {
116
+ fail("commit must be a 7-char hex SHA when set");
117
+ }
118
+ if (present(fm.merge_strategy) &&
119
+ !MERGE_STRATEGIES.has(String(fm.merge_strategy))) {
120
+ fail(`merge_strategy must be one of: ${[...MERGE_STRATEGIES].join(", ")}`);
121
+ }
122
+ if ("author" in fm &&
123
+ (typeof fm.author !== "string" || fm.author.trim() === "")) {
124
+ fail("author must be a non-empty string when present");
125
+ }
126
+ if ("co_authors" in fm && !isStringArray(fm.co_authors)) {
127
+ fail("co_authors must be an array of strings (use [] when none)");
128
+ }
129
+ if ("category" in fm && !CATEGORIES.has(String(fm.category))) {
130
+ fail(`category must be one of: ${[...CATEGORIES].join(", ")}`);
131
+ }
132
+ if ("breaking" in fm && typeof fm.breaking !== "boolean") {
133
+ fail("breaking must be a boolean");
134
+ }
135
+ if ("issues" in fm) {
136
+ if (isStringArray(fm.issues)) {
137
+ for (const id of fm.issues) {
138
+ if (!ISSUE_RE.test(id)) {
139
+ fail(`issues entry ${JSON.stringify(id)} must match [A-Z]{2,}-\\d+`);
140
+ }
141
+ }
142
+ }
143
+ else {
144
+ fail("issues must be an array of strings when present");
145
+ }
146
+ }
147
+ // PR stats live under stats: { files_changed, loc_added, loc_removed }.
148
+ const statKeys = ["files_changed", "loc_added", "loc_removed"];
149
+ for (const key of statKeys) {
150
+ if (key in fm) {
151
+ fail(`${key} must be under stats, not top-level`);
152
+ }
153
+ }
154
+ // stats is optional (filled by enrichment), but must be a well-formed object
155
+ // with non-negative integer values when present.
156
+ if (present(fm.stats)) {
157
+ if (typeof fm.stats !== "object" || Array.isArray(fm.stats)) {
158
+ fail("stats must be an object");
159
+ }
160
+ else {
161
+ const stats = fm.stats;
162
+ for (const key of statKeys) {
163
+ if (key in stats && present(stats[key]) && !isNonNegInt(stats[key])) {
164
+ fail(`stats.${key} must be a non-negative integer when set`);
165
+ }
166
+ }
167
+ }
168
+ }
169
+ // The schema (changelog/README.md) requires "## Breaking" to be the FIRST
170
+ // body section when breaking: true — not merely present somewhere.
171
+ if (fm.breaking === true) {
172
+ const firstSection = body.match(/^##\s+([A-Za-z]+)\b/m)?.[1];
173
+ if (firstSection !== "Breaking") {
174
+ fail('breaking: true requires "## Breaking" as the first body section');
175
+ }
176
+ }
177
+ if (!SECTION_RE.test(body)) {
178
+ fail("body must contain at least one of: ## Breaking | ## Added | ## Changed | ## Fixed");
179
+ }
180
+ return errors;
181
+ }
182
+ function listEntries(directory) {
183
+ let stat;
184
+ try {
185
+ stat = statSync(directory);
186
+ }
187
+ catch {
188
+ console.error(`changelog directory not found: ${directory}`);
189
+ process.exit(2);
190
+ }
191
+ if (!stat.isDirectory()) {
192
+ console.error(`${directory} is not a directory`);
193
+ process.exit(2);
194
+ }
195
+ return readdirSync(directory)
196
+ .filter((name) => name.endsWith(".md") && name !== "README.md")
197
+ .map((name) => join(directory, name));
198
+ }
199
+ function main() {
200
+ const files = listEntries(CHANGELOG_DIR);
201
+ const errors = [];
202
+ for (const file of files) {
203
+ errors.push(...validateEntry(basename(file), readFileSync(file, "utf8")));
204
+ }
205
+ if (errors.length > 0) {
206
+ console.error(`Changelog validation failed with ${errors.length} error(s):\n`);
207
+ for (const message of errors) {
208
+ console.error(` - ${message}`);
209
+ }
210
+ process.exit(1);
211
+ }
212
+ console.log(`Changelog validation passed (${files.length} entr${files.length === 1 ? "y" : "ies"} checked).`);
213
+ }
214
+ if (import.meta.url === `file://${process.argv[1]}`) {
215
+ main();
216
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"astro.d.ts","sourceRoot":"","sources":["../../rules/astro.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGrC;;;;;;;GAOG;AACH,eAAO,MAAM,KAAK,EAAE,MAAM,CAAC,MAAM,EAmBhC,CAAC"}
1
+ {"version":3,"file":"astro.d.ts","sourceRoot":"","sources":["../../rules/astro.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAGrC;;;;;;;GAOG;AACH,eAAO,MAAM,KAAK,EAAE,MAAM,CAAC,MAAM,EAsBhC,CAAC"}