diff --git a/README.md b/README.md index 41c0090..b9bc6b8 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ You can also override or add rules: | [ui/sentence-case](docs/rules/ui/sentence-case.md) | Enforce sentence case for UI strings | | ✅ ![badge-recommendedWithLocalesEn][] | 🔧 | | [ui/sentence-case-json](docs/rules/ui/sentence-case-json.md) | Enforce sentence case for English JSON locale strings | | | 🔧 | | [ui/sentence-case-locale-module](docs/rules/ui/sentence-case-locale-module.md) | Enforce sentence case for English TS/JS locale module strings | | | 🔧 | +| [validate-license](docs/rules/validate-license.md) | Validate the structure of copyright notices in LICENSE files for Obsidian plugins. | | | | | [validate-manifest](docs/rules/validate-manifest.md) | Validate the structure of manifest.json for Obsidian plugins. | ✅ ![badge-recommendedWithLocalesEn][] | | | | [vault/iterate](docs/rules/vault/iterate.md) | Avoid iterating all files to find a file by its path
| ✅ ![badge-recommendedWithLocalesEn][] | | 🔧 | diff --git a/docs/rules/validate-license.md b/docs/rules/validate-license.md new file mode 100644 index 0000000..ddad0cb --- /dev/null +++ b/docs/rules/validate-license.md @@ -0,0 +1,3 @@ +# Validate the structure of copyright notices in LICENSE files for Obsidian plugins (`obsidianmd/validate-license`) + + diff --git a/lib/index.ts b/lib/index.ts index 1970eca..7ae0ba2 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -16,8 +16,10 @@ import preferFileManagerTrashFile from "./rules/preferFileManagerTrashFile.js"; import regexLookbehind from "./rules/regexLookbehind.js"; import sampleNames from "./rules/sampleNames.js"; import validateManifest from "./rules/validateManifest.js"; +import validateLicense from "./rules/validateLicense.js"; import { manifest } from "./readManifest.js"; import { ui } from "./rules/ui/index.js"; +import { PlainTextParser } from "./plainTextParser.js"; // --- Import plugins and configs for the recommended config --- import js from "@eslint/js"; @@ -57,6 +59,7 @@ const plugin: ESLint.Plugin = { "regex-lookbehind": regexLookbehind, "sample-names": sampleNames, "validate-manifest": validateManifest, + "validate-license": validateLicense, "ui/sentence-case": ui.sentenceCase, "ui/sentence-case-json": ui.sentenceCaseJson, "ui/sentence-case-locale-module": ui.sentenceCaseLocaleModule, @@ -188,6 +191,20 @@ const flatRecommendedConfig = [ "obsidianmd/validate-manifest": "error", }, }, + { + // LICENSE validation + plugins: { + obsidianmd: plugin, + }, + parserOptions: { + parser: PlainTextParser, + extraFileExtensions: [""], + }, + files: ["LICENSE"], + rules: { + "obsidianmd/validate-license": "error", + }, + }, ] as any; const hybridRecommendedConfig = { diff --git a/lib/plainTextParser.ts b/lib/plainTextParser.ts new file mode 100644 index 0000000..657ccd3 --- /dev/null +++ b/lib/plainTextParser.ts @@ -0,0 +1,51 @@ +import { AST_NODE_TYPES, AST_TOKEN_TYPES } from "@typescript-eslint/types"; +import type { Parser } from "@typescript-eslint/utils/ts-eslint"; + +/** + * A plain text parser for ESLint. + * It treats each line as a separate token of type `Line`. + * This allows us to lint plain text files like LICENSE files. + */ +export const PlainTextParser: Parser.ParserModule = { + meta: { + name: "plain-text-parser", + version: "1.0.0", + }, + parseForESLint(text: string): Parser.ParseResult { + const lines = text.split("\n"); + + const tokens: Parser.ParseResult["ast"]["tokens"] = []; + + let index = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + tokens.push({ + // Line is originally supposed to represent a multi-line comment, + // but we use it to represent a line of text here. The identifier fits best. + type: AST_TOKEN_TYPES.Line, + value: line, + range: [index, index + line.length], + loc: { + start: { line: i + 1, column: 0 }, + end: { line: i + 1, column: line.length }, + }, + }); + index += line.length + 1; // +1 for the newline character + } + + return { + ast: { + type: AST_NODE_TYPES.Program, + sourceType: "script", + range: [0, text.length], + loc: { + start: tokens[0]?.loc.start ?? { line: 1, column: 0 }, + end: tokens[tokens.length - 1].loc.end ?? { line: 1, column: 0 }, + }, + body: [], + comments: [], + tokens: tokens, + }, + }; + } +} \ No newline at end of file diff --git a/lib/rules/validateLicense.ts b/lib/rules/validateLicense.ts new file mode 100644 index 0000000..ede9a26 --- /dev/null +++ b/lib/rules/validateLicense.ts @@ -0,0 +1,87 @@ +import { AST_TOKEN_TYPES, ESLintUtils, TSESTree } from "@typescript-eslint/utils"; +import path from "path"; + +const ruleCreator = ESLintUtils.RuleCreator( + (name) => + `https://github.com/obsidianmd/eslint-plugin/blob/master/docs/rules/${name}.md`, +); + +export default ruleCreator({ + name: "validate-license", + meta: { + type: "problem" as const, + docs: { + description: "Validate the structure of copyright notices in LICENSE files for Obsidian plugins.", + url: "???", + }, + schema: [ + { + type: "object", + properties: { + currentYear: { + type: "number", + description: "The current year to validate against.", + }, + disableUnchangedYear: { + type: "boolean", + description: "If true, do not report errors for unchanged years.", + default: false, + } + } + } + ], + messages: { + unchangedCopyright: "Please change the copyright holder from \"Dynalist Inc.\" to your name.", + unchangedYear: "Please change the copyright year from {{actual}} to the current year ({{expected}}).", + }, + }, + defaultOptions: [{ + currentYear: new Date().getFullYear(), + disableUnchangedYear: false, + }], + create(context, [options]) { + const filename = context.physicalFilename; + if (!path.basename(filename).endsWith("LICENSE")) { + return {}; + } + + return { + Program(programNode: TSESTree.Program) { + // We want to parse: Copyright (C) 2020-2025 by Dynalist Inc. + // We should check that the year is current and the holder is not "Dynalist Inc." + + const copyrightRegex = /^(?: |\t)*Copyright \(C\) (\d{4})(?:-(\d{4}))? by (.+)$/; + + // we rely on our plain text parser to give us tokens as Line tokens + for (const token of programNode.tokens ?? []) { + if (token.type !== AST_TOKEN_TYPES.Line) continue; + + const match = token.value.match(copyrightRegex); + if (match) { + const startYear = parseInt(match[1], 10); + const endYear = match[2] ? parseInt(match[2], 10) : startYear; + const holder = match[3].trim(); + + if (!options.disableUnchangedYear && endYear < options.currentYear) { + context.report({ + messageId: "unchangedYear", + loc: token.loc, + data: { + expected: options.currentYear.toString(), + actual: endYear.toString(), + } + }); + } + + if (holder === "Dynalist Inc.") { + context.report({ + messageId: "unchangedCopyright", + loc: token.loc, + }); + } + } + } + } + }; + }, +}); \ No newline at end of file diff --git a/tests/all-rules.test.ts b/tests/all-rules.test.ts index e27e80e..71e62b4 100644 --- a/tests/all-rules.test.ts +++ b/tests/all-rules.test.ts @@ -14,6 +14,7 @@ import "./noTFileTFolderCast.test"; import "./noViewReferencesInPlugin.test"; import "./noStaticStylesAssignment.test"; import "./validateManifest.test"; +import "./validateLicense.test"; import "./noPluginAsComponent.test"; import "./preferAbstractInputSuggest.test"; import "./noSampleCode.test"; diff --git a/tests/validateLicense.test.ts b/tests/validateLicense.test.ts new file mode 100644 index 0000000..caffffa --- /dev/null +++ b/tests/validateLicense.test.ts @@ -0,0 +1,108 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import licenseRule from "../lib/rules/validateLicense.js"; +import { PlainTextParser } from "lib/plainTextParser.js"; + +const ruleTester = new RuleTester({ + languageOptions: { + parser: PlainTextParser, + parserOptions: { + extraFileExtensions: [""], + } + }, + +}); +const currentYear = new Date().getFullYear(); + +ruleTester.run("validate-license", licenseRule, { + valid: [ + { + filename: "LICENSE", + code: `Copyright (C) 2020-${currentYear} by John Doe`, + }, + { + filename: "LICENSE", + code: `Copyright (C) ${currentYear} by John Doe`, + }, + { + filename: "LICENSE", + code: `Copyright (C) ${currentYear + 1} by John Doe`, + }, + { + filename: "LICENSE", + code: `Copyright (C) 2000 by John Doe`, + options: [{ currentYear: 2000, disableUnchangedYear: false }], + }, + { + filename: "LICENSE", + code: `Copyright (C) 2001 by John Doe`, + options: [{ currentYear: 2000, disableUnchangedYear: false }], + }, + { + filename: "LICENSE", + code: `Copyright (C) 2001 by John Doe`, + options: [{ currentYear: currentYear, disableUnchangedYear: true }], + }, + { + filename: "LICENSE", + code: `foo\nCopyright (C) 2020-${currentYear} by John Doe\nbar`, + }, + { + filename: "LICENSE", + code: `foo\nbar\nbaz`, + }, + ], + invalid: [ + { + filename: "LICENSE", + code: `Copyright (C) 2020-${currentYear} by Dynalist Inc.`, + errors: [ + { messageId: "unchangedCopyright" } + ], + }, + { + filename: "LICENSE", + code: `Copyright (C) 2020-2022 by John Doe`, + errors: [ + { messageId: "unchangedYear", data: { expected: currentYear.toString(), actual: "2022" } } + ], + }, + { + filename: "LICENSE", + code: `Copyright (C) 2022 by John Doe`, + errors: [ + { messageId: "unchangedYear", data: { expected: currentYear.toString(), actual: "2022" } } + ], + }, + { + filename: "LICENSE", + code: `Copyright (C) 2020-2022 by Dynalist Inc.`, + errors: [ + { messageId: "unchangedYear", data: { expected: currentYear.toString(), actual: "2022" } }, + { messageId: "unchangedCopyright" } + ], + }, + { + filename: "LICENSE", + code: `Copyright (C) 2020-2022 by John Doe\nCopyright (C) 2020-${currentYear} by Dynalist Inc.`, + errors: [ + { messageId: "unchangedYear", data: { expected: currentYear.toString(), actual: "2022" } }, + { messageId: "unchangedCopyright" } + ], + }, + { + filename: "LICENSE", + code: `bar\nCopyright (C) 2020-${currentYear} by Dynalist Inc.\nbaz`, + errors: [ + { messageId: "unchangedCopyright" } + ], + }, + { + filename: "LICENSE", + code: `Copyright (C) 1999 by John Doe`, + options: [{ currentYear: 2000, disableUnchangedYear: false }], + errors: [ + { messageId: "unchangedYear", data: { expected: "2000", actual: "1999" } } + ], + }, + ], +}); \ No newline at end of file