diff --git a/.gitignore b/.gitignore index 6690d0d..6a6d210 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ !/packages/*/*.config.ts ### Sources ### +!/packages/*/bin/*.js !/packages/*/src/**/*.ts !/packages/*/src/**/*.tsx diff --git a/.vscode/settings.template.json b/.vscode/settings.template.json index c0c62cd..1ebd98c 100644 --- a/.vscode/settings.template.json +++ b/.vscode/settings.template.json @@ -18,5 +18,8 @@ "--workspaces", "--if-present", "pretest" - ] + ], + "nodejs-testing.debugOptions": { + "autoAttachChildProcesses": true + } } diff --git a/packages/conventional/LICENSE b/packages/conventional/LICENSE new file mode 100644 index 0000000..68a9d1b --- /dev/null +++ b/packages/conventional/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 M. George Hansen + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/conventional/README.md b/packages/conventional/README.md new file mode 100644 index 0000000..3e515a7 --- /dev/null +++ b/packages/conventional/README.md @@ -0,0 +1,117 @@ +# @websnacksjs/conventional + +A lightweight, zero-depenency cli tool for enforcing [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0/) standards on commit messages. + +Designed as a simpler and opinionated alternative to [`commitlint`](https://commitlint.js.org/) with a convention-over-configuration philosophy. + +## ๐Ÿ“ฆ Installation + +```bash +npm install @websnacksjs/conventional +# or +yarn add @websnacksjs/conventional +# or +pnpm add @websnacksjs/conventional +``` + +## ๐Ÿš€ Quick Start + +[`huksy`](https://typicode.github.io/husky/) is recommended as a lightweight way to automate configuration of git commit hooks in local checkouts of your code: + +```bash +npm install --save-dev husky && npx husky init +``` + +Once `husky` is installed and configured, using `@websnacksjs/conventional` is as simple as adding a new `.husky/commit-msg` file: + +**`./.husky/commit-msg`** + +```bash +npx conventional commit-msg "$1" +``` + +From now on, commit messages will be validated to meet the conventional commit v1.0.0 specification by `@websnacksjs/conventional`. + +## ๐Ÿ“– Usage Details + +### โš™๏ธ User-defined commit message validations + +By default, `@websnacksjs/conventional` will only ensure that commit messages are valid conventional commit message. If you want to add additional validations (such as only allowing certain scopes or commit types), you can add a `conventional.config.js` file to the root of your repository: + +**`./conventional.config.js`** + +```js +import { defineConfig } from '@websnacksjs/conventional'; + +const validTypes = ['feat', 'fix', 'chore']; + +export default defineConfig({ + validateCommitMessage(commitMessage) { + // commitMessage is a parsed version of the conventional commit message + if (!validTypes.includes(commitMessage.scope)) { + throw new Error(`${commitMessage.scope} is not a valid scope`) + } + } +}); +``` + +Now, properly formatted conventional commit messages that aren't one of "feat", "fix", or "chore" will be rejected. + +### ๐Ÿงน Normalized whitespace & formatting + +`@websnacksjs/conventional` will correct poor formatting in commit messages automatically. This includes: + +- Removing leading & trailing whitespace in commit message scopes, descriptions, bodies, and footer values; +- Adding correct new lines to separate commit message summary lines from bodies and footers; +- Collapsing whitespace in commit message scopes, descriptions, bodies, and breaking changes and footers; +- Converting the first character to lowercase in descriptions and footer values; +- Adding missing puncuation to commit message descriptions, bodies, and breaking changes; +- Converting the first character to uppercase in commit message bodies and breaking changes; +- Converting "BREAKING-CHANGE" footers to "BREAKING CHANGE" for consistency. +- Adding breaking signifier "!" to commit message summaries when BREAKING CHANGE footers are present. + +For example: + +```plaintext +feat( subpackage-a ): added lots of new features +adds a lot of neat stuff + +you should check it out brah! + +BREAKING-CHANGE: it's gonna break production, gurranteed + +authored-by: Someone + +``` + +Is normalized to: + +```plaintext +feat(subpackage-a)!: added lots of new features + +Adds a lot of neat stuff. + +You should check it out brah! + +BREAKING CHANGE: it's gonna break production, gurranteed + +Authored-by: Someone +``` + +Additionally, it tries to detect common mistakes such as missing full commit descriptions and bodies that have a trailing comma: + +```plaintext +feat(subpackage-a): added new feature, +// ^ Rejected: trailing comma indicates potential copy-paste error and partly missing description +``` + +And rejecting commits with summaries over 80 lines long (which get truncated in most git UIs): + +```plaintext +feat(subpackage-a): this adds TONS of new features such as walking your dog, buying groceries, paying your bills, and more! +// ^ Rejected: commit summary is over 80 lines long and would result in truncation +``` + +## ๐Ÿ“œ License + +`@websnacksjs/conventional` is licensed under the Apache-2.0 license. See the [LICENSE](/LICENSE) file for details. diff --git a/packages/conventional/bin/conventional.js b/packages/conventional/bin/conventional.js new file mode 100755 index 0000000..dcc2ac0 --- /dev/null +++ b/packages/conventional/bin/conventional.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import "../dist/bin/main.js"; diff --git a/packages/conventional/package.json b/packages/conventional/package.json new file mode 100644 index 0000000..c6c1c27 --- /dev/null +++ b/packages/conventional/package.json @@ -0,0 +1,30 @@ +{ + "name": "@websnacksjs/conventional", + "version": "0.1.0", + "description": "A conventional commit cli application", + "keywords": [ + "websnacks", + "conventional-commit", + "git" + ], + "author": "M. George Hansen ", + "license": "Apache-2.0", + "type": "module", + "bin": "bin/conventional.js", + "exports": { + ".": "./dist/lib/index.js" + }, + "files": [ + "dist/", + "src/" + ], + "scripts": { + "build": "tsc --build", + "test": "tsc --noEmit --project tests/tsconfig.json && node --experimental-strip-types --disable-warning=ExperimentalWarning --test", + "pretest": "npm run build", + "prepack": "npm run build" + }, + "devDependencies": { + "ts-poet": "^6.12.0" + } +} diff --git a/packages/conventional/src/bin/commit-msg.ts b/packages/conventional/src/bin/commit-msg.ts new file mode 100644 index 0000000..da93224 --- /dev/null +++ b/packages/conventional/src/bin/commit-msg.ts @@ -0,0 +1,291 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import { findConfig } from "../lib/config.js"; +import type { CommitMessage, Footer } from "../lib/types.js"; +import { removeElements, uniqueBy } from "../lib/utils/array.js"; +import { formatError, normalizeError } from "../lib/utils/error.js"; +import { + assertNoPunctuation, + collapseWhitespace, + lowerCaseFirstCharacter, + properlyPuncuate, + removeComments, + removeTrailingPunctuation, + unwrapText, + upperCaseFirstCharacter, +} from "../lib/utils/string.js"; +import wrap from "../lib/utils/wrap.js"; + +const summaryRegex = + /^(?\w+)(?:\((?[^()]+)\))?(?!)?: (?.+)/; +const footerRegex = /^(?[\w-]+|BREAKING CHANGE): (?.+)/; + +const normalizeDescription = (description: string): string => { + description = collapseWhitespace(description); + description = lowerCaseFirstCharacter(description); + description = removeTrailingPunctuation(description); + return description; +}; + +const normalizeType = (type: string): string => { + type = type.toLowerCase(); + return type; +}; + +const normalizeScope = (scope: string): string => { + scope = collapseWhitespace(scope); + scope = scope.toLowerCase(); + return scope; +}; + +const normalizeBody = (body: string): string => { + body = body.trim(); + if (body === "") { + return ""; + } + + // If the user already wrapped their body text, join it back up. + body = unwrapText(body); + body = body + .split("\n") + .map((paragraph) => { + paragraph = collapseWhitespace(paragraph); + paragraph = upperCaseFirstCharacter(paragraph); + paragraph = properlyPuncuate(paragraph); + return paragraph; + }) + .join("\n\n"); + return body; +}; + +const BREAKING_CHANGE_KEY = "BREAKING CHANGE"; + +const normalizeBreakingChange = (breakingChanges: string): string => { + breakingChanges = collapseWhitespace(breakingChanges); + breakingChanges = lowerCaseFirstCharacter(breakingChanges); + breakingChanges = removeTrailingPunctuation(breakingChanges); + assertNoPunctuation(breakingChanges); + return breakingChanges; +}; + +const normalizeFooter = (footer: Footer): Footer => { + // Copy input param so we don't modify it accidentally. + footer = { ...footer }; + + let [firstWord, ...trailingWords] = footer.key.split("-") as [ + string, + ...string[], + ]; + firstWord = upperCaseFirstCharacter(firstWord); + trailingWords = trailingWords.map(lowerCaseFirstCharacter); + footer.key = [firstWord, ...trailingWords].join("-"); + + footer.value = footer.value.trim(); + footer.value = collapseWhitespace(footer.value); + + return footer; +}; + +const normalizeFooters = (footers: Footer[]): Footer[] => { + footers = uniqueBy(footers, (footer) => `${footer.key}: ${footer.value}`); + footers = footers.map(normalizeFooter); + return footers; +}; + +const parseCommitMessageBody = ( + lines: string[], +): { body: string; breakingChanges: string[]; footers: Footer[] } => { + // Parse the body until we see a line that looks like a footer. + const bodyLines = []; + lines = lines.toReversed(); + let line = lines.pop(); + while (line !== undefined) { + if (line.match(footerRegex)) { + lines.push(line); + break; + } + + bodyLines.push(line); + + line = lines.pop(); + } + let body = bodyLines.join("\n"); + try { + body = normalizeBody(body); + } catch (cause) { + throw new Error(`commit message body malformed`, { + cause: normalizeError(cause), + }); + } + + // Remaining lines must be footers + let footers: Footer[] = []; + let additionalFooterLines: string[] = []; + const appendLinesToLastFooter = () => { + if (additionalFooterLines.length === 0) { + return; + } + + const lastFooter = footers[footers.length - 1]; + if (!lastFooter) { + throw new Error( + `found lines that don't look like a footer and don't belong to any other footer: ` + + `${JSON.stringify(additionalFooterLines.join("\n"))}`, + ); + } + + for (const additionalLine of additionalFooterLines) { + lastFooter.value += `\n${additionalLine}`; + } + additionalFooterLines = []; + }; + // Reverse lines since we're no longer using pop() to remove the body lines. + lines = lines.toReversed(); + for (const line of lines) { + const match = line.match(footerRegex); + // We may be on a multiline footer, so continue until we see another + // footer declared. + if (!match) { + additionalFooterLines.push(line); + continue; + } + + appendLinesToLastFooter(); + + const { key, value } = match.groups as { key: string; value: string }; + footers.push({ key, value }); + } + appendLinesToLastFooter(); + + let breakingChanges = removeElements( + footers, + ({ key }) => key === BREAKING_CHANGE_KEY || key === "BREAKING-CHANGE", + ).map(({ value }) => value); + breakingChanges = breakingChanges.map(normalizeBreakingChange); + + footers = normalizeFooters(footers); + return { + body, + breakingChanges, + footers, + }; +}; + +const parseCommitMessage = (message: string): CommitMessage => { + message = removeComments(message); + + const [firstLine, ...lines] = message.split(/\r?\n/) as [ + string, + ...string[], + ]; + const summaryMatch = firstLine.match(summaryRegex); + if (!summaryMatch) { + throw new Error( + "commit message is not in a conventional commit v1.0.0 compliant format (see https://www.conventionalcommits.org/en/v1.0.0/)", + ); + } + + let { + type, + scope = "", + breaking = "", + description, + } = summaryMatch.groups as { + type: string; + scope: string | undefined; + breaking: string | undefined; + description: string; + }; + type = normalizeType(type); + scope = normalizeScope(scope); + try { + description = normalizeDescription(description); + } catch (cause) { + throw new Error(`commit message description malformed`, { + cause: normalizeError(cause), + }); + } + + const { body, breakingChanges, footers } = parseCommitMessageBody(lines); + const isBreaking = breaking !== "" || breakingChanges.length > 0; + + return { + type, + scope, + isBreaking, + description, + body, + breakingChanges, + footers, + }; +}; + +const MAX_LINE_LENGTH = 72; + +const serializeCommitMessage = ({ + type, + scope, + isBreaking, + description, + body, + breakingChanges, + footers, +}: CommitMessage): string => { + const summaryLine = `${type}${scope ? `(${scope})` : ""}${isBreaking ? "!" : ""}: ${description}`; + if (summaryLine.length > MAX_LINE_LENGTH) { + throw new Error( + `commit message summary line exceeds max length of ${MAX_LINE_LENGTH} characters`, + ); + } + let result = summaryLine; + + if (body) { + result += `\n\n${wrap(body, { width: MAX_LINE_LENGTH })}`; + } + + const breakingChangeLines = breakingChanges.map( + (breakingChange) => `${BREAKING_CHANGE_KEY}: ${breakingChange}`, + ); + if (breakingChangeLines.length > 0) { + const breakingChanges = breakingChangeLines.join("\n"); + result += `\n\n${breakingChanges}`; + } + + const footerLines = footers.map(({ key, value }) => `${key}: ${value}`); + if (footerLines.length > 0) { + result += `\n\n${footerLines.join("\n")}`; + } + + return result; +}; + +const main = async (): Promise => { + const messageFile = process.argv[2]; + assert.ok(messageFile, "expected message file as first parameter"); + + const config = await findConfig(); + + const userCommitMessage = await fs.readFile(messageFile, "utf-8"); + const commitMessage = parseCommitMessage(userCommitMessage); + + await (async () => config.validateCommitMessage?.(commitMessage))().catch( + (cause) => { + throw new Error( + `user-defined validateCommitMessage(...) rejected commit message`, + { cause: normalizeError(cause) }, + ); + }, + ); + + // Write back the commit message to normalize spacing and newlines. + await fs.writeFile(messageFile, serializeCommitMessage(commitMessage)); +}; + +await main() + .catch(normalizeError) + .then((error) => { + if (error) { + console.error(`โ›” Error: ${formatError(error)}`); + process.exit(1); + } + }); diff --git a/packages/conventional/src/bin/main.ts b/packages/conventional/src/bin/main.ts new file mode 100644 index 0000000..341a9c6 --- /dev/null +++ b/packages/conventional/src/bin/main.ts @@ -0,0 +1,10 @@ +const [command = ""] = process.argv.splice(2, 1); +switch (command) { + case "commit-msg": { + await import("./commit-msg.js"); + break; + } + default: { + console.error(`โ›” unknown command ${command}`); + } +} diff --git a/packages/conventional/src/lib/config.ts b/packages/conventional/src/lib/config.ts new file mode 100644 index 0000000..0d1e056 --- /dev/null +++ b/packages/conventional/src/lib/config.ts @@ -0,0 +1,102 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { CommitMessage } from "./types.js"; +import { normalizeError } from "./utils/error.js"; + +export type ConventionalConfig = { + validateCommitMessage?(message: CommitMessage): void | Promise; +}; + +export type ConventionalUserConfig = Partial; + +export const defineConfig = ( + config: ConventionalUserConfig, +): ConventionalUserConfig => config; + +function validateConfig( + value: unknown, +): asserts value is ConventionalUserConfig { + if (typeof value !== "object") { + throw new Error(`not an object`); + } + + if (value === null) { + throw new Error(`is null`); + } + + if ( + "validateCommitMessage" in value && + typeof value.validateCommitMessage !== "function" + ) { + throw new Error(`validateCommitMessage is not a function`); + } +} + +const isErrnoException = (value: unknown): value is NodeJS.ErrnoException => { + if (!(value instanceof Error)) { + return false; + } + + if ("errno" in value && typeof value.errno !== "number") { + return false; + } + + if ("code" in value && typeof value.code !== "string") { + return false; + } + + if ("path" in value && typeof value.path !== "string") { + return false; + } + + if ("syscall" in value && typeof value.syscall !== "string") { + return false; + } + + return true; +}; + +const DEFAULT_CONFIG: ConventionalUserConfig = {}; + +export const findConfig = async (): Promise => { + const configDir = new URL(`file://${process.cwd()}/`); + do { + const configFile = new URL("./conventional.config.js", configDir); + try { + await fs.stat(configFile); + } catch (error) { + if (!isErrnoException(error) || error.code !== "ENOENT") { + throw error; + } + + configDir.pathname = `${configDir.pathname.split("/").slice(0, -2).join("/")}/`; + continue; + } + const config = await import(configFile.toString()) + .then((mod: object) => { + if (!("default" in mod)) { + throw new Error( + `no default export in ${fileURLToPath(configFile)}`, + ); + } + return mod.default; + }) + .catch((cause) => { + const relativeConfigFile = path.relative( + process.cwd(), + fileURLToPath(configFile), + ); + throw new Error( + `failed to load config from ./${relativeConfigFile}`, + { + cause: normalizeError(cause), + }, + ); + }); + validateConfig(config); + return config; + } while (configDir.pathname !== "/"); + + return DEFAULT_CONFIG; +}; diff --git a/packages/conventional/src/lib/index.ts b/packages/conventional/src/lib/index.ts new file mode 100644 index 0000000..4426218 --- /dev/null +++ b/packages/conventional/src/lib/index.ts @@ -0,0 +1,3 @@ +export type * from "./config.js"; +export { defineConfig } from "./config.js"; +export type * from "./types.js"; diff --git a/packages/conventional/src/lib/types.ts b/packages/conventional/src/lib/types.ts new file mode 100644 index 0000000..42a2c8a --- /dev/null +++ b/packages/conventional/src/lib/types.ts @@ -0,0 +1,14 @@ +export type Footer = { + key: string; + value: string; +}; + +export type CommitMessage = { + type: string; + scope?: string | undefined; + isBreaking: boolean; + description: string; + body?: string | undefined; + breakingChanges: string[]; + footers: Footer[]; +}; diff --git a/packages/conventional/src/lib/utils/array.ts b/packages/conventional/src/lib/utils/array.ts new file mode 100644 index 0000000..cbc9230 --- /dev/null +++ b/packages/conventional/src/lib/utils/array.ts @@ -0,0 +1,30 @@ +export const uniqueBy = (items: T[], keyFn: (item: T) => string): T[] => { + const seen = new Set(); + items = items.filter((x) => { + const key = keyFn(x); + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + return items; +}; + +export const removeElements = ( + elements: T[], + pred: (elem: T) => boolean, +): T[] => { + const removedElements: T[] = []; + let newLength = 0; + for (const elem of elements) { + if (pred(elem)) { + removedElements.push(elem); + continue; + } + + elements[newLength++] = elem; + } + elements.length = newLength; + return removedElements; +}; diff --git a/packages/conventional/src/lib/utils/error.ts b/packages/conventional/src/lib/utils/error.ts new file mode 100644 index 0000000..861962e --- /dev/null +++ b/packages/conventional/src/lib/utils/error.ts @@ -0,0 +1,28 @@ +export const normalizeError = (error: unknown): Error => { + if (!(error instanceof Error)) { + error = new Error(`non-Error type thrown: ${JSON.stringify(error)}`); + } + return error as Error; +}; + +const errorCauseChain = (error: Error): Error[] => { + const causeChain: Error[] = []; + let cause = error.cause; + while (cause !== undefined) { + causeChain.push(normalizeError(cause)); + + if (cause instanceof Error) { + cause = cause.cause; + } + } + return causeChain; +}; + +export const formatError = (error: Error): string => { + let message = error.message; + const causeChain = errorCauseChain(error); + for (const cause of causeChain) { + message += `: ${cause.message}`; + } + return message; +}; diff --git a/packages/conventional/src/lib/utils/string.ts b/packages/conventional/src/lib/utils/string.ts new file mode 100644 index 0000000..6e223e3 --- /dev/null +++ b/packages/conventional/src/lib/utils/string.ts @@ -0,0 +1,73 @@ +export const collapseWhitespace = (str: string): string => { + str = str.trim(); + // Convert all tabs to spaces. + str = str.replaceAll(/\t/g, " "); + str = str.replaceAll(/ +/g, " "); + return str; +}; + +export const lowerCaseFirstCharacter = (str: string): string => { + const firstChar = str[0]; + if (!firstChar) { + // str is empty + return ""; + } + + return `${firstChar.toLowerCase()}${str.slice(1)}`; +}; + +export const upperCaseFirstCharacter = (str: string): string => { + const firstChar = str[0]; + if (!firstChar) { + // str is empty + return ""; + } + + return `${firstChar.toUpperCase()}${str.slice(1)}`; +}; + +export const assertNoTrailingComma = (str: string): void => { + // Trailing commas could indicate that the user is missing part of the + // string (e.g. copy-and-paste error). + if (str.endsWith(",")) { + throw new Error( + `ends w/ trailing comma "," (did you include the entire string?)`, + ); + } +}; + +export const assertNoPunctuation = (str: string): void => { + if (str.match(/[.!?]|\.\.\./)) { + throw new Error(`contains punctuation`); + } +}; + +export const removeTrailingPunctuation = (str: string): string => { + assertNoTrailingComma(str); + + str = str.replace(/\W+$/, ""); + return str; +}; + +export const properlyPuncuate = (str: string): string => { + assertNoTrailingComma(str); + + if (!str.match(/[.!?]|\.\.\.$/)) { + str = `${str}.`; + } + return str; +}; + +export const unwrapText = (str: string): string => { + const paragraphs = str.split("\n\n"); + return paragraphs + .map((paragraph) => paragraph.split("\n").join(" ")) + .join("\n"); +}; + +export const removeComments = (str: string): string => { + return str + .split("\n") + .filter((line) => !line.startsWith("#")) + .join("\n"); +}; diff --git a/packages/conventional/src/lib/utils/wrap.ts b/packages/conventional/src/lib/utils/wrap.ts new file mode 100644 index 0000000..a2fbdf6 --- /dev/null +++ b/packages/conventional/src/lib/utils/wrap.ts @@ -0,0 +1,50 @@ +/*! + * word-wrap + * + * Copyright (c) 2014-2023, Jon Schlinkert. + * Released under the MIT License. + */ + +const trimEnd = (str: string): string => { + let lastCharPos = str.length - 1; + let lastChar = str[lastCharPos]; + while (lastChar === " " || lastChar === "\t") { + lastChar = str[--lastCharPos]; + } + return str.substring(0, lastCharPos + 1); +}; + +const trimTabAndSpaces = (str: string): string => { + const lines = str.split("\n"); + const trimmedLines = lines.map((line) => trimEnd(line)); + return trimmedLines.join("\n"); +}; + +export interface WrapOptions { + /** + * The width of the text before wrapping to a new line. + */ + width: number; +} + +export default function wrap(str: string, { width }: WrapOptions): string { + if (str === "") { + return str; + } + + let regexString = `.{1,${width}}`; + regexString += "([\\s\u200B]+|$)|[^\\s\u200B]+?([\\s\u200B]+|$)"; + const re = new RegExp(regexString, "g"); + const lines = str.match(re) || []; + let result = lines + .map((line) => { + if (line.slice(-1) === "\n") { + line = line.slice(0, line.length - 1); + } + return line; + }) + .join("\n"); + + result = trimTabAndSpaces(result); + return result; +} diff --git a/packages/conventional/tests/features/commit-msg.test.ts b/packages/conventional/tests/features/commit-msg.test.ts new file mode 100644 index 0000000..8ecffe1 --- /dev/null +++ b/packages/conventional/tests/features/commit-msg.test.ts @@ -0,0 +1,469 @@ +import assert from "node:assert/strict"; +import { fork } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import inspector from "node:inspector"; +import os from "node:os"; +import path from "node:path"; +import { + after, + afterEach, + before, + beforeEach, + describe, + it, + mock, +} from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { code } from "ts-poet"; + +const detectInspectorPort = (): string | undefined => { + let inspectorUrl: URL | string | undefined = inspector.url(); + if (!inspectorUrl) { + return; + } + inspectorUrl = new URL(inspectorUrl); + return inspectorUrl.port; +}; + +const runCommand = async ( + messageFile: URL, + { + signal, + }: { + signal: AbortSignal; + }, +): Promise<{ stderr: string; stdout: string; code: number }> => { + const binFile = new URL("../../bin/conventional.js", import.meta.url); + const inspectorPort = detectInspectorPort(); + const command = fork(binFile, ["commit-msg", fileURLToPath(messageFile)], { + signal, + stdio: ["ipc", "pipe", "pipe"], + execArgv: + inspectorPort != null + ? [ + "--inspect", + "--inspect-port=0", + // Ensure that inspector output isn't redirectd to + // stderr which can mess up our tests that assert on + // stderr. + "--inspect-publish-uid=http", + ] + : [], + }); + + let stderr = ""; + command.stderr?.setEncoding("utf-8"); + command.stderr?.on("data", (chunk) => { + stderr += chunk; + }); + + let stdout = ""; + command.stdout?.setEncoding("utf-8"); + command.stdout?.on("data", (chunk) => { + stdout += chunk; + }); + + const code = await new Promise((resolve) => { + command.once("close", (code) => resolve(code ?? 0)); + }); + stderr = stderr.trim(); + stdout = stdout.trim(); + return { + stderr, + stdout, + code, + }; +}; + +const previousCwd = process.cwd(); +let tempDir: URL; +before(async () => { + tempDir = await fs + .mkdtemp(path.join(os.tmpdir(), "websnacks-conventional-")) + .then(pathToFileURL); + // Add trailing slash so relative URLs get properly appended. + tempDir.pathname = `${tempDir.pathname}/`; + process.chdir(fileURLToPath(tempDir)); +}); +after(async () => { + process.chdir(previousCwd); + await fs.rm(tempDir, { recursive: true, force: true }); +}); + +const mockCommitMessage = async (message: string): Promise => { + const messageFile = new URL(`./message-${randomUUID()}`, tempDir); + await fs.writeFile(messageFile, message, "utf-8"); + return messageFile; +}; + +afterEach(() => { + mock.reset(); +}); + +describe("conventional commit-msg ...", () => { + describe("when commit message is missing conventional commit summary line", () => { + let messageFile: URL; + beforeEach(async () => { + messageFile = await mockCommitMessage("fixes an issue"); + }); + + it("produces an appropriate error", async ({ signal }) => { + assert.partialDeepStrictEqual( + await runCommand(messageFile, { signal }), + { + code: 1, + stderr: [ + "โ›” Error: commit message is not in a conventional commit v1.0.0 compliant format", + "(see https://www.conventionalcommits.org/en/v1.0.0/)", + ].join(" "), + }, + ); + }); + }); + + describe("when commit message contains only a properly formatted summary line & whitespace", () => { + let messageFile: URL; + beforeEach(async () => { + messageFile = await mockCommitMessage( + ["fix(something): fixes something, I think", "", ""].join("\n"), + ); + }); + + it("outputs properly formatted commit message", async ({ signal }) => { + assert.partialDeepStrictEqual( + await runCommand(messageFile, { signal }), + { + code: 0, + }, + ); + assert.equal( + await fs.readFile(messageFile, "utf-8"), + "fix(something): fixes something, I think", + ); + }); + }); + + describe('when commit message description ends with a comma ","', () => { + let messageFile: URL; + beforeEach(async () => { + messageFile = await mockCommitMessage( + "fix: resolves issue where things wouldn't work,", + ); + }); + + it("rejects the commit message w/ an appropriate error", async ({ + signal, + }) => { + assert.partialDeepStrictEqual( + await runCommand(messageFile, { signal }), + { + code: 1, + stderr: [ + "โ›” Error: commit message description malformed:", + 'ends w/ trailing comma "," (did you include the entire string?)', + ].join(" "), + }, + ); + }); + }); + + describe('when commit message body ends with a comma ","', () => { + let messageFile: URL; + beforeEach(async () => { + messageFile = await mockCommitMessage( + [ + "fix: resolves issue where things wouldn't work", + "", + "This fixes that annoying bug reported last week,", + ].join("\n"), + ); + }); + + it("rejects the commit message w/ an appropriate error", async ({ + signal, + }) => { + assert.partialDeepStrictEqual( + await runCommand(messageFile, { signal }), + { + code: 1, + stderr: 'โ›” Error: commit message body malformed: ends w/ trailing comma "," (did you include the entire string?)', + }, + ); + }); + }); + + describe("when commit message summary line is longer than 72 characters", () => { + let messageFile: URL; + beforeEach(async () => { + messageFile = await mockCommitMessage( + [ + "feat(subpackage-a): this adds TONS of new features such as", + "walking your dog, buying groceries, paying your bills, and", + "more!", + ].join(" "), + ); + }); + + it("rejects commit message w/ appropriate error message", async ({ + signal, + }) => { + assert.partialDeepStrictEqual( + await runCommand(messageFile, { signal }), + { + code: 1, + stderr: "โ›” Error: commit message summary line exceeds max length of 72 characters", + }, + ); + }); + }); + + describe("when commit message contains comments", () => { + let messageFile: URL; + beforeEach(async () => { + messageFile = await mockCommitMessage( + [ + "feat(subpackage-a): added lots of new features", + "", + "This adds a LOT of new features. Like, an incredible amount!", + "", + "# Please enter the commit message for your changes. Lines starting", + "# with '#' will be ignored, and an empty message aborts the commit.", + "#", + "# Date: Mon Aug 18 16:24:29 2025 +0000", + ].join("\n"), + ); + }); + + it("removes comments from the comment message", async ({ signal }) => { + assert.partialDeepStrictEqual( + await runCommand(messageFile, { signal }), + { + code: 0, + stderr: "", + }, + ); + assert.equal( + await fs.readFile(messageFile, "utf-8"), + [ + "feat(subpackage-a): added lots of new features", + "", + "This adds a LOT of new features. Like, an incredible amount!", + ].join("\n"), + ); + }); + }); + + describe("when commit message is valid conventional commit but is improperly formatted", () => { + let messageFile: URL; + beforeEach(async () => { + messageFile = await mockCommitMessage( + [ + "feat( subpackage-a ): added lots of new features", + "adds a lot of neat stuff", + "", + "you should check it out brah!", + "", + "BREAKING-CHANGE: It's gonna break production, gurranteed.", + "", + "", + "authored-By: Someone", + " ", + ].join("\n"), + ); + }); + + it("formats commit message to normalize whitespace, capitalization & punctuation", async ({ + signal, + }) => { + assert.partialDeepStrictEqual( + await runCommand(messageFile, { signal }), + { + code: 0, + stderr: "", + }, + ); + assert.equal( + await fs.readFile(messageFile, "utf-8"), + [ + "feat(subpackage-a)!: added lots of new features", + "", + "Adds a lot of neat stuff.", + "", + "You should check it out brah!", + "", + "BREAKING CHANGE: it's gonna break production, gurranteed", + "", + "Authored-by: Someone", + ].join("\n"), + ); + }); + }); + + describe("when commit message body exceeds max line length of 72 characters", () => { + let messageFile: URL; + beforeEach(async () => { + messageFile = await mockCommitMessage( + [ + "feat(subpackage-a): added lots of new features", + "", + [ + "Lorem ipsum dolor sit amet, consectetur adipiscing", + "elit, sed do eiusmod tempor incididunt ut labore et", + "dolore magna aliqua. Ut enim ad minim veniam, quis", + "https://example.com/some/very/very/very/very/long-path/with-hyphens.html", + "nostrud exercitation ullamco laboris nisi ut aliquip", + "ex ea commodo consequat.", + ].join(" "), + "", + "", + ].join("\n"), + ); + }); + + it("wraps message body at 72 character rule where feasible", async ({ + signal, + }) => { + assert.partialDeepStrictEqual( + await runCommand(messageFile, { signal }), + { + code: 0, + }, + ); + assert.equal( + await fs.readFile(messageFile, "utf-8"), + [ + "feat(subpackage-a): added lots of new features", + "", + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod", + "tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim", + "veniam, quis", + "https://example.com/some/very/very/very/very/long-path/with-hyphens.html", + "nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo", + "consequat.", + ].join("\n"), + ); + }); + }); + + describe('when commit message has "BREAKING CHANGE" footer', () => { + let messageFile: URL; + beforeEach(async () => { + messageFile = await mockCommitMessage( + [ + "fix(@websnacksjs/conventional): changes a few things", + "", + "BREAKING CHANGE: this will break production, guaranteed", + ].join("\n"), + ); + }); + + it("marks commit message summary as breaking", async ({ signal }) => { + assert.partialDeepStrictEqual( + await runCommand(messageFile, { signal }), + { + code: 0, + stderr: "", + }, + ); + const [firstLine] = await fs + .readFile(messageFile, "utf-8") + .then((message) => message.split("\n")); + assert.equal( + firstLine, + "fix(@websnacksjs/conventional)!: changes a few things", + ); + }); + }); + + describe("when user provides a config file that fails to load", () => { + let configFile: URL; + before(async () => { + configFile = new URL("./conventional.config.js", tempDir); + await fs.writeFile( + configFile, + code` + export default = + `.toString(), + "utf-8", + ); + }); + after(async () => { + await fs.rm(configFile); + }); + + it("provides an appropriate error message", async ({ signal }) => { + const messageFile = await mockCommitMessage( + ["feat(good): this is a good thing, probably"].join("\n"), + ); + assert.partialDeepStrictEqual( + await runCommand(messageFile, { signal }), + { + code: 1, + stderr: [ + `โ›” Error: failed to load config from ./conventional.config.js:`, + `Unexpected token '='`, + ].join(" "), + }, + ); + }); + }); + + describe("when user provides config file w/ validateCommitMessage(...)", () => { + let configFile: URL; + before(async () => { + configFile = new URL("./conventional.config.js", tempDir); + await fs.writeFile( + configFile, + code` + export default { + validateCommitMessage(message) { + if (message.scope !== "good") { + throw new Error(\`only "good" is accepted as a valid scope\`) + } + } + }; + `.toString(), + "utf-8", + ); + }); + after(async () => { + await fs.rm(configFile); + }); + + it("rejects commit message that DON'T satisfy user validation function", async ({ + signal, + }) => { + const messageFile = await mockCommitMessage( + "feat(bad): adds a terrible new feature, beware", + ); + + assert.partialDeepStrictEqual( + await runCommand(messageFile, { signal }), + { + code: 1, + stderr: [ + `โ›” Error: user-defined validateCommitMessage(...) rejected commit message:`, + `only "good" is accepted as a valid scope`, + ].join(" "), + }, + ); + }); + + it("approves commit message that DO satisfy user validation function", async ({ + signal, + }) => { + const messageFile = await mockCommitMessage( + "feat(good): adds a terrible new feature, beware", + ); + + assert.partialDeepStrictEqual( + await runCommand(messageFile, { signal }), + { + code: 0, + stdout: "", + }, + ); + }); + }); +}); diff --git a/packages/conventional/tests/tsconfig.json b/packages/conventional/tests/tsconfig.json new file mode 100644 index 0000000..434f6bb --- /dev/null +++ b/packages/conventional/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["./**/*"] +} diff --git a/packages/conventional/tsconfig.json b/packages/conventional/tsconfig.json new file mode 100644 index 0000000..8edec98 --- /dev/null +++ b/packages/conventional/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": ["../../tsconfig.common.json"], + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json index b600258..88ef00b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,9 @@ { "files": [], "references": [ + { + "path": "./packages/conventional" + }, { "path": "./packages/i18n" }