diff --git a/.eslintignore b/.eslintignore index 687a559a..cbea34f2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -41,3 +41,7 @@ package-lock.json # Playwright test output e2e-tests/playwright-report e2e-tests/test-results + +# A verbatim copy of the template's manifest, kept byte-for-byte so `npm run template:baseline` can +# write it straight from `git show` rather than a copy plus whatever our tooling would impose on it +scripts/merged-template-package.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..4f9033e8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,80 @@ +# Dependabot is scoped to the packages this extension adds on top of +# paranext-extension-template. Everything else in package.json comes from the +# template and is updated only when we merge template changes, so letting +# Dependabot raise PRs for those would put our lockfile ahead of the template's +# and create conflicts at the next merge. An allow list filters security updates +# as well as version updates, so nothing template-owned is touched either way. +# +# Keep the allow list in sync with package.json: an entry belongs here if, and +# only if, it is absent from the template's package.json. The `file:` +# dependencies are the exception: the ignore list below skips them instead, for +# the reason recorded beside it. `npm run lint:dependencies` checks that. +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: monthly + # The `file:` dependencies resolve against a sibling paranext-core + # checkout, which exists on developer machines and in CI but not inside + # Dependabot's container. npm's file fetcher resolves path dependencies + # before any update is considered, and it consults `ignore` — never `allow` — + # when deciding to skip one. Omitting them from the allow list below is + # therefore not enough on its own: without these entries they become + # unfetchable and the job aborts during file fetching. + ignore: + - dependency-name: platform-bible-utils + - dependency-name: papi-dts + - dependency-name: platform-bible-react + allow: + # dependencies + - dependency-name: '@reduxjs/toolkit' + - dependency-name: fast-xml-parser + - dependency-name: react-redux + # devDependencies + - dependency-name: '@playwright/test' + - dependency-name: '@testing-library/jest-dom' + - dependency-name: '@testing-library/react' + - dependency-name: '@testing-library/user-event' + - dependency-name: '@types/jest' + - dependency-name: '@types/ws' + - dependency-name: eslint-plugin-jest + - dependency-name: jest + - dependency-name: jest-environment-jsdom + - dependency-name: js-yaml + - dependency-name: ts-jest + - dependency-name: ws + groups: + # The tooling we add on top of the template moves together, so one PR per + # cycle rather than one per package. Runtime dependencies stay out: those + # ship to users. Major bumps stay ungrouped too, being worth reading on + # their own. + dev-tooling: + applies-to: version-updates + update-types: ['minor', 'patch'] + patterns: + - '@playwright/test' + - '@testing-library/*' + - '@types/jest' + - '@types/ws' + - eslint-plugin-jest + - jest + - jest-environment-jsdom + - js-yaml + - ts-jest + - ws + + # No allow list here: we pin every action to a SHA while the template pins by + # tag, so no line Dependabot rewrites is a line the template also owns. + # Dependabot updates the SHA and the `# v1.2.3` comment beside it together. + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + groups: + # Grouped for the same reason as the npm group above. + actions: + applies-to: version-updates + update-types: ['minor', 'patch'] + patterns: + - '*' diff --git a/.prettierignore b/.prettierignore index 01f359ac..53cf3e5e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -41,3 +41,7 @@ package-lock.json # Playwright test output e2e-tests/playwright-report e2e-tests/test-results + +# A verbatim copy of the template's manifest, kept byte-for-byte so `npm run template:baseline` can +# write it straight from `git show` rather than a copy plus whatever our tooling would impose on it +scripts/merged-template-package.json diff --git a/.stylelintignore b/.stylelintignore index 69c75a68..3214a7f1 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -41,3 +41,7 @@ package-lock.json # Playwright test output e2e-tests/playwright-report e2e-tests/test-results + +# A verbatim copy of the template's manifest, kept byte-for-byte so `npm run template:baseline` can +# write it straight from `git show` rather than a copy plus whatever our tooling would impose on it +scripts/merged-template-package.json diff --git a/AGENTS.md b/AGENTS.md index 08a50558..4e0ff592 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ npm run build:web-view # Build React WebView only npm run watch # Continuous rebuild on changes # Lint & Format -npm run lint # Run ESLint + stylelint + tsc --noEmit +npm run lint # Run ESLint + stylelint + tsc --noEmit + dependency scope npm run lint-fix # Auto-fix linting issues npm run format # Format with Prettier @@ -25,6 +25,8 @@ npm test -- path/to/file.test.ts # Run a single test file npm test -- --testNamePattern="pattern" # Run tests matching name ``` +Only a template merge moves the dependency baseline `npm run lint:dependencies` checks against; refresh it in that same commit with `npm run template:baseline`, which writes both the copy and the `MERGED_TEMPLATE_COMMIT` id recorded beside it in [scripts/check-dependency-scope.cjs](scripts/check-dependency-scope.cjs). [README.md](README.md) has the full procedure. + ## Architecture This is a **Platform.Bible extension** for interlinear Bible text alignment. Platform.Bible (PAPI) is an Electron-based application; extensions run in a sandboxed context and communicate with the host via `papi.*` APIs. diff --git a/README.md b/README.md index b4aeb428..5197d410 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,14 @@ git fetch template git merge template/main --allow-unrelated-histories ``` +Merging is also what moves the baseline `npm run lint:dependencies` compares dependency versions against, so in the same commit refresh that baseline from the template you just merged: + +```bash +npm run template:baseline +``` + +Run that before fetching the template again, so the baseline records the state you actually merged. It writes both the copy and the `MERGED_TEMPLATE_COMMIT` id recorded beside it in [`scripts/check-dependency-scope.cjs`](scripts/check-dependency-scope.cjs), so the two cannot drift apart. Nothing resolves that id — it is there so the check's output names a template state you can go and look at. + For more information, read [the instructions on the wiki](https://github.com/paranext/paranext-extension-template/wiki/Merging-Template-Changes-into-Your-Extension). After updating this extension from the template, clear all temp/cache files and regenerate the extension's `package-lock.json` with this command: @@ -327,6 +335,8 @@ npm run core:reinstall **Note:** The merge/squash commits created when updating this repo from the template are important; Git uses them to compare the files for future updates. If you edit this repo's Git history, please preserve these commits (do not squash them, for example) to avoid duplicated merge conflicts in the future. +Dependabot covers only the packages this extension adds on top of the template, so that its updates never move a template-owned dependency ahead of the template. `npm run lint:dependencies` enforces that split: it compares `package.json` against the template's and reports any package whose version range has drifted, plus any mismatch between the extension's own packages and the allow list in [`.github/dependabot.yml`](.github/dependabot.yml), whose grouping patterns and allow list it holds to each other: a departing package cannot leave its grouping line behind, and a dev dependency this extension adds cannot slip out of the group that spares it a pull request of its own. Entries on the allow and ignore lists are literal package names: the check reports an entry it cannot read that way — a wildcard, or one selecting packages by dependency type — rather than guessing at what it covers. A version range this extension holds apart from the template's on purpose belongs in the recorded list at the top of [`scripts/check-dependency-scope.cjs`](scripts/check-dependency-scope.cjs). The template side of the comparison is [`scripts/merged-template-package.json`](scripts/merged-template-package.json), a verbatim copy of the template's manifest as of the commit this repo last merged, so a range difference means this extension moved the range: bumps the template makes between merges are ours to pick up at the next merge rather than a lint failure to fix now. Only a template merge moves that baseline; Dependabot's own updates never do, because its allow list covers only packages the template does not own. + ## Special features in this project This project has special features and specific configuration to make building an extension for Platform.Bible easier. Rather than duplicating the full explanation here, please refer to the [`Special Features in this project` section of the multi-extension template README](https://github.com/paranext/paranext-multi-extension-template?tab=readme-ov-file#special-features-in-this-project) for details on these features. diff --git a/package-lock.json b/package-lock.json index 485045bb..1cb7ce28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "glob": "^10.5.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", + "js-yaml": "^4.3.0", "lucide-react": "^1.8.0", "papi-dts": "file:../paranext-core/lib/papi-dts", "platform-bible-react": "file:../paranext-core/lib/platform-bible-react", diff --git a/package.json b/package.json index 17b6d092..18720963 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "start": "cross-env MAIN_ARGS=\"--extensions $INIT_CWD/dist\" concurrently \"npm:watch\" \"npm:core:start\"", "start:cdp": "cross-env MAIN_ARGS=\"--extensions $INIT_CWD/dist --remote-debugging-port=9223\" concurrently \"npm:watch\" \"npm:core:start\"", "start:production": "cross-env MAIN_ARGS=\"--extensions $INIT_CWD/dist\" concurrently \"npm:watch:production\" \"npm:core:start\"", - "lint": "npm run lint:scripts && npm run lint:styles && npm run lint:typecheck", + "lint": "npm run lint:scripts && npm run lint:styles && npm run lint:typecheck && npm run lint:dependencies", + "lint:dependencies": "node ./scripts/check-dependency-scope.cjs", "lint:scripts": "cross-env NODE_ENV=development eslint --ext .cjs,.js,.jsx,.ts,.tsx --cache .", "lint:styles": "stylelint **/*.{css,scss} --allow-empty-input", "lint:typecheck": "tsc --noEmit", @@ -40,7 +41,8 @@ "core:reinstall": "npm run core:stop && node ./scripts/delete-temp-files.cjs --all && npm run core:update && npm i", "core:install": "npm --prefix ../paranext-core install", "core:pull": "git -C ../paranext-core pull --ff-only", - "core:update": "npm run core:pull && npm run core:install" + "core:update": "npm run core:pull && npm run core:install", + "template:baseline": "node ./scripts/refresh-template-baseline.cjs" }, "browserslist": [], "peerDependencies": { @@ -95,6 +97,7 @@ "glob": "^10.5.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", + "js-yaml": "^4.3.0", "lucide-react": "^1.8.0", "papi-dts": "file:../paranext-core/lib/papi-dts", "platform-bible-react": "file:../paranext-core/lib/platform-bible-react", diff --git a/scripts/check-dependency-scope.cjs b/scripts/check-dependency-scope.cjs new file mode 100644 index 00000000..42836222 --- /dev/null +++ b/scripts/check-dependency-scope.cjs @@ -0,0 +1,400 @@ +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); +const { fail, failWith } = require('./report-failure.cjs'); + +/** + * Cross-checks `package.json` against paranext-extension-template's and against + * `.github/dependabot.yml`, enforcing the rule the Dependabot config states in its header: a + * package belongs on the allow list if, and only if, it is absent from the template's + * `package.json` — `file:` dependencies excepted, since Dependabot skips those by way of its ignore + * list instead. The group patterns and that allow list are held to each other in turn, so a package + * leaving takes its grouping line with it and an added dev dependency cannot slip out of the group + * that spares it a pull request of its own. Exits non-zero on any violation. + */ + +const REPO_ROOT = path.join(__dirname, '..'); +const OUR_MANIFEST_PATH = path.join(REPO_ROOT, 'package.json'); +const DEPENDABOT_CONFIG_PATH = path.join(REPO_ROOT, '.github', 'dependabot.yml'); + +/** + * The template's `package.json` as of the commit this repo has merged, copied in verbatim. README's + * update instructions refresh it in the same commit that merges the template. + * + * Holding the comparison to a fixed template state rather than the template's moving head is what + * lets a range difference mean "this extension moved the range": the template bumps its own ranges + * between merges, and those bumps are for the next merge to adopt rather than a lint failure in the + * meantime. + * + * The baseline is a copy because it cannot be a git reference. A template update reaches `main` + * squashed as readily as merged — #204 did — and a squash leaves the template's own commits + * unreachable from this repo, so resolving a commit id would fail on every build once an update + * lands that way. A copy also puts each move of the baseline in a reviewable diff. + */ +const MERGED_TEMPLATE_MANIFEST_PATH = path.join(__dirname, 'merged-template-package.json'); + +/** + * The commit {@link MERGED_TEMPLATE_MANIFEST_PATH} was copied from, recorded so that output names a + * template state a reader can go and look at. Nothing resolves it — the copy beside it is what this + * check reads — so nothing here would catch the two disagreeing. `npm run template:baseline` writes + * both, which is what keeps them in step; set this by hand only to repair a refresh that went + * wrong. + */ +const MERGED_TEMPLATE_COMMIT = 'c2a2f07ce9faf1674340fba64e069f2e58a0eb09'; + +const SHORT_COMMIT = MERGED_TEMPLATE_COMMIT.slice(0, 7); + +/** + * A baseline left behind by a template merge reads that merge's own bumps as this extension's, so + * every violation comes out inverted and advises undoing the merge. Nothing in the manifests tells + * that case from real drift, so every violation the comparison reports carries the possibility. + */ +const STALE_BASELINE_HINT = `If these came in with a template merge, refresh ${path.basename(MERGED_TEMPLATE_MANIFEST_PATH)} from the template commit that merge brought in, rather than acting on the lines above. Run npm run template:baseline while template/main still points at that commit.`; + +const UNREADABLE_BASELINE_HINT = + 'A template merge leaves conflict markers in this copy as readily as in any other file. Resolve them by hand, or run npm run template:baseline while template/main still points at the commit that merge brought in, which rewrites the copy outright.'; + +const UNREADABLE_MANIFEST_HINT = + 'Every npm command reads this file, so the rest of the toolchain is down alongside this check until it is readable again.'; + +const UNSCOPED_DEPENDABOT_CONFIG_HINT = + 'The allow, ignore, and group lists this check holds package.json to live in that file, declared by the one npm ecosystem entry scoping the directory package.json sits in.'; + +/** + * Version ranges this extension deliberately holds apart from the template's. Each entry records + * both sides, so it covers that one divergence and no other: change either range and the entry + * stops matching, which puts the pair back in front of a human. + */ +const RECORDED_RANGE_DIVERGENCES = [ + { + name: '@tailwindcss/postcss', + template: '^4.0.0', + ours: '^4.3.0', + reason: + 'Narrowed in 791ffd6 alongside the React 19 / Tailwind 4 upgrade. Every version it admits also satisfies the template range.', + }, + { + name: 'tailwindcss', + template: '^4.0.0', + ours: '^4.3.0', + reason: + 'Narrowed in 791ffd6 alongside the React 19 / Tailwind 4 upgrade. Every version it admits also satisfies the template range.', + }, +]; + +/** Whether a version range resolves against a sibling checkout rather than the registry. */ +function isFileDependency(range) { + return range !== undefined && range.startsWith('file:'); +} + +/** + * The manifest's dependency sections merged, since Dependabot scopes them as one npm ecosystem. + * `overrides` stays out: it pins transitive versions rather than naming packages this extension + * depends on, so the allow-list rule has nothing to say about it. + * + * `peerDependencies` stays in, so a package declared only there is still held to the template's + * range. It reads the allow-list rule wrong in return: Dependabot's npm updater raises no version + * update for a peer range, so a peer dependency beyond the template would be asked for an allow + * entry that can raise nothing. Nothing declares one; exempt the section from that rule alone if + * anything ever does. + */ +function collectDependencies(manifest) { + return { ...manifest.dependencies, ...manifest.devDependencies, ...manifest.peerDependencies }; +} + +/** + * @throws When the manifest is missing or is not JSON, naming the file — the baseline copy collects + * conflict markers on a template merge as readily as any other file does. + */ +function readManifest(manifestPath) { + try { + return JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch (error) { + throw new Error(`Could not read ${path.relative(REPO_ROOT, manifestPath)}: ${error.message}`); + } +} + +/** + * The package names the npm ecosystem entry allows and ignores, the patterns its groups collect, + * and the keys those groups declare, the last two each paired with the group they came from. + * + * An entry naming no package — Dependabot also selects by dependency type — leaves `undefined` in + * its place on the list rather than being dropped, since dropping it would widen the scope this + * check reads package.json against without saying so. + * + * @throws When the config is missing or is not YAML, and when it declares anything other than one + * npm ecosystem: none would read as an empty scope that passes every check, and a second scopes + * another directory's package.json, which is not the manifest this check opens. + */ +function readNpmScope() { + const configPath = path.relative(REPO_ROOT, DEPENDABOT_CONFIG_PATH); + + let config; + try { + config = yaml.load(fs.readFileSync(DEPENDABOT_CONFIG_PATH, 'utf8')); + } catch (error) { + throw new Error(`Could not read ${configPath}: ${error.message}`); + } + + const npmEntries = (config?.updates ?? []).filter( + (entry) => entry['package-ecosystem'] === 'npm', + ); + if (npmEntries.length === 0) throw new Error(`No npm ecosystem entry in ${configPath}`); + if (npmEntries.length > 1) + throw new Error( + `${npmEntries.length} npm ecosystem entries in ${configPath}, and this check reads one — name the directory each covers here, or teach this check to pick out the one scoping the package.json beside it`, + ); + const [npmEntry] = npmEntries; + + const dependencyNames = (entries) => (entries ?? []).map((entry) => entry['dependency-name']); + const definitions = Object.entries(npmEntry.groups ?? {}); + const groups = definitions.flatMap(([group, definition]) => + (definition?.patterns ?? []).map((pattern) => ({ group, pattern })), + ); + const groupKeys = definitions.flatMap(([group, definition]) => + Object.keys(definition ?? {}).map((key) => ({ group, key })), + ); + return { + allow: dependencyNames(npmEntry.allow), + ignore: dependencyNames(npmEntry.ignore), + groups, + groupKeys, + }; +} + +/** + * Entries on the allow and ignore lists that this check cannot read as one package's name: + * wildcards, which it has no matching for, and entries naming no package at all. Dependabot accepts + * them, so nothing else would report one as out of scope, and an entry selecting packages by type + * covers a set this check cannot hold package.json to. + */ +function findUnsupportedEntries(scope) { + return [ + { list: 'allow', names: scope.allow }, + { list: 'ignore', names: scope.ignore }, + ].flatMap(({ list, names }) => + names.flatMap((name, index) => { + if (name === undefined) + return `${list} entry ${index + 1}: no dependency-name, so it names no package — name each package it covers outright, or teach this check to read the entry`; + if (name.includes('*')) + return `${name}: a wildcard on Dependabot's ${list} list, which this check reads as literal package names — name each package it covers outright, or teach this check to match wildcards`; + return []; + }), + ); +} + +/** + * The group keys that leave a group readable by its patterns alone: the one this check reads, and + * those narrowing which updates a group collects rather than which packages. + */ +const READABLE_GROUP_KEYS = ['patterns', 'applies-to', 'update-types']; + +/** + * Keys a group declares that this check cannot read, each named with the group declaring it. + * Dependabot accepts more than {@link READABLE_GROUP_KEYS}, and a key narrowing which packages a + * group collects — `exclude-patterns` and `dependency-type` both do — leaves a package the group + * drops reading as collected, which {@link findUngroupedDevDependencies} would then pass over in + * silence. + */ +function findUnsupportedGroupKeys(scope) { + return scope.groupKeys + .filter(({ key }) => !READABLE_GROUP_KEYS.includes(key)) + .map( + ({ group, key }) => + `${key}: declared by Dependabot's ${group} group, and this check reads a group as the packages its patterns collect — a key narrowing that further leaves a package the group drops reading as grouped, so teach this check to read it, or drop the key`, + ); +} + +/** + * Whether a package name is one of those a Dependabot group pattern collects. `*` is the only + * wildcard read and matching is case-sensitive, where Dependabot also takes `?` and character + * classes and folds case, so a pattern relying on any of that collects less here than it does + * there. + */ +function matchesPattern(pattern, name) { + const anchored = pattern + .replace(/[.+?^${}()|[\]\\]/g, '\\$&') + .split('*') + .join('.*'); + return new RegExp(`^${anchored}$`).test(name); +} + +/** + * Group patterns collecting nothing on the allow list. A group only ever collects updates + * Dependabot is already raising, so a pattern that matches nothing there is a line a departed + * package left behind. + */ +function findStaleGroupPatterns(scope) { + return scope.groups + .filter(({ pattern }) => !scope.allow.some((name) => matchesPattern(pattern, name))) + .map( + ({ group, pattern }) => + `${pattern}: in Dependabot's ${group} group but matches nothing on the allow list, so it groups no update`, + ); +} + +/** + * Packages named on both the allow and the ignore list. Dependabot filters the allow list through + * the ignore list, so the pair leaves the allow entry raising nothing while reading as though the + * package were still receiving updates. + */ +function findPackagesOnBothLists(scope) { + return scope.allow + .filter((name) => scope.ignore.includes(name)) + .map( + (name) => + `${name}: on both Dependabot's allow and ignore lists, and ignoring wins, so the allow entry raises nothing — drop whichever of the two the package does not need`, + ); +} + +/** + * Allowed dev dependencies that no group collects. Grouping is what holds this extension's tooling + * to one pull request a cycle, and nothing about a package announces that it was meant to be in a + * group, so an addition drifts outside every pattern silently. + */ +function findUngroupedDevDependencies(manifest, scope) { + const devDependencies = manifest.devDependencies ?? {}; + return scope.allow + .filter( + (name) => + name in devDependencies && + !scope.groups.some(({ pattern }) => matchesPattern(pattern, name)), + ) + .map( + (name) => + `${name}: a dev dependency on the allow list that no group collects, so its updates arrive as a pull request of their own — add it to a group, or record here why it stays out`, + ); +} + +/** + * Where the manifests and the allow list disagree. + * + * @returns {string[]} One line per violation; empty when the scoping rule holds. + */ +function findManifestViolations(ours, template, scope) { + const violations = []; + const recordedByName = new Map(RECORDED_RANGE_DIVERGENCES.map((entry) => [entry.name, entry])); + + RECORDED_RANGE_DIVERGENCES.forEach((recorded) => { + const ourRange = ours[recorded.name]; + const templateRange = template[recorded.name]; + if (ourRange === recorded.ours && templateRange === recorded.template) return; + if (ourRange === undefined && templateRange === undefined) { + violations.push( + `${recorded.name}: recorded as a divergence but in neither package.json — the entry in ${path.basename(__filename)} outlived the package it covers, so drop it`, + ); + return; + } + violations.push( + `${recorded.name}: recorded divergence is stale — it records template ${recorded.template} against ours ${recorded.ours} ("${recorded.reason}"), but the manifests now read template ${templateRange ?? '(absent)'} against ours ${ourRange ?? '(absent)'}`, + ); + }); + + Object.entries(ours).forEach(([name, range]) => { + if (recordedByName.has(name) || !(name in template) || template[name] === range) return; + violations.push( + `${name}: ${range} moved off the template's ${template[name]} — sync it back, or record the divergence in ${path.basename(__filename)}`, + ); + }); + + Object.entries(ours).forEach(([name, range]) => { + if (name in template || isFileDependency(range) || scope.allow.includes(name)) return; + violations.push( + `${name}: absent from the template's package.json, so it needs a Dependabot allow entry to receive updates`, + ); + }); + + scope.allow.forEach((name) => { + if (!(name in ours)) + violations.push(`${name}: on Dependabot's allow list but no longer in package.json`); + else if (name in template) + violations.push( + `${name}: on Dependabot's allow list but the template owns it, so template merges will fight its updates`, + ); + }); + + Object.entries(ours).forEach(([name, range]) => { + if (!isFileDependency(range) || scope.ignore.includes(name)) return; + violations.push( + `${name}: a file: dependency missing from Dependabot's ignore list, which aborts its file fetcher`, + ); + }); + + scope.ignore.forEach((name) => { + if (isFileDependency(ours[name])) return; + if (!(name in ours)) + violations.push(`${name}: on Dependabot's ignore list but no longer in package.json`); + else + violations.push( + `${name}: on Dependabot's ignore list, which exists for file: dependencies — an ignore entry with another purpose needs this check updated`, + ); + }); + + return violations; +} + +/** + * Runs `read`, reporting whatever it throws as a failure with a way out of it. Left to Node's + * default handler, the same message arrives buried in a stack trace and carrying no hint at all. + */ +function readOrFail(read, hint) { + try { + return read(); + } catch (error) { + fail(error.message, hint); + } +} + +const templateManifest = readOrFail( + () => readManifest(MERGED_TEMPLATE_MANIFEST_PATH), + UNREADABLE_BASELINE_HINT, +); +const ourManifest = readOrFail(() => readManifest(OUR_MANIFEST_PATH), UNREADABLE_MANIFEST_HINT); +const scope = readOrFail(readNpmScope, UNSCOPED_DEPENDABOT_CONFIG_HINT); + +const templateDependencies = collectDependencies(templateManifest); +const ours = collectDependencies(ourManifest); + +const unsupportedEntries = findUnsupportedEntries(scope); +const unsupportedGroupKeys = findUnsupportedGroupKeys(scope); + +// The checks here read a group as the packages its patterns collect, so a key narrowing that +// further leaves them reporting on a membership the group does not have. They read the allow list +// as literal names too, so an entry that cannot be read holds them back as well. +const groupViolations = + unsupportedGroupKeys.length > 0 + ? unsupportedGroupKeys + : [...findStaleGroupPatterns(scope), ...findUngroupedDevDependencies(ourManifest, scope)]; + +// The checks below read the allow and ignore lists as literal names, so an entry they cannot read +// leaves every package it covers unaccounted for and the entry itself looking departed. Hold them +// until it is gone. +const configViolations = + unsupportedEntries.length > 0 + ? [...unsupportedEntries, ...unsupportedGroupKeys] + : [...findPackagesOnBothLists(scope), ...groupViolations]; +const manifestViolations = + unsupportedEntries.length > 0 ? [] : findManifestViolations(ours, templateDependencies, scope); +const violations = [...configViolations, ...manifestViolations]; + +console.log( + `Comparing package.json against the template at ${SHORT_COMMIT}, the commit this repo has merged`, +); + +const templateOnly = Object.keys(templateDependencies).filter((name) => !(name in ours)); +if (templateOnly.length > 0) { + console.log( + `⊘ In that template commit but not here, so this extension has dropped them: ${templateOnly.join(', ')}`, + ); +} + +if (violations.length > 0) { + const lines = violations.map((violation) => `✗ ${violation}`); + // The hint offers a stale baseline as the explanation, and only the comparison reads one. This + // repo owns the Dependabot config outright, so no template merge can have written what the rest + // report. + if (manifestViolations.length > 0) lines.push(`ℹ ${STALE_BASELINE_HINT}`); + failWith(lines); +} + +console.log('✓ Dependabot scope matches the packages this extension adds to the template'); diff --git a/scripts/merged-template-package.json b/scripts/merged-template-package.json new file mode 100644 index 00000000..09b731fa --- /dev/null +++ b/scripts/merged-template-package.json @@ -0,0 +1,105 @@ +{ + "name": "paranext-extension-template", + "private": true, + "version": "0.0.1", + "main": "src/main.js", + "types": "src/types/paranext-extension-template.d.ts", + "author": "Paranext", + "license": "MIT", + "scripts": { + "build:web-view": "webpack --config ./webpack/webpack.config.web-view.ts", + "build:main": "webpack --config ./webpack/webpack.config.main.ts", + "build": "webpack", + "watch": "npm run build -- --watch", + "build:production": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=false webpack", + "watch:production": "npm run build:production -- --watch", + "format": "prettier --write .", + "format:check": "prettier --check .", + "zip": "zip-build dist release --template '%NAME%_%VERSION%.%EXT%' --override", + "package": "npm run build:production && npm run zip", + "package:debug": "cross-env DEBUG_PROD=true npm run package", + "start:core": "cd ../paranext-core && npm run start", + "start": "cross-env MAIN_ARGS=\"--extensions $INIT_CWD/dist\" concurrently \"npm:watch\" \"npm:start:core\"", + "start:production": "cross-env MAIN_ARGS=\"--extensions $INIT_CWD/dist\" concurrently \"npm:watch:production\" \"npm:start:core\"", + "lint": "npm run lint:scripts && npm run lint:styles", + "lint:scripts": "cross-env NODE_ENV=development eslint --ext .cjs,.js,.jsx,.ts,.tsx --cache .", + "lint:styles": "stylelint **/*.{css,scss} --allow-empty-input", + "lint-fix": "npm run lint-fix:scripts && npm run lint:styles -- --fix", + "lint-fix:scripts": "npm run format && npm run lint:scripts", + "bump-versions": "ts-node ./lib/bump-versions.ts" + }, + "browserslist": [], + "peerDependencies": { + "react": ">=19.0.0", + "react-dom": ">=19.0.0" + }, + "dependencies": { + "@sillsdev/scripture": "^2.0.5", + "platform-bible-utils": "file:../paranext-core/lib/platform-bible-utils" + }, + "devDependencies": { + "@dreamsicle.io/stylelint-config-tailwindcss": "^1.2.2", + "@fontsource-variable/ibm-plex-sans": "^5.2.8", + "@stylistic/eslint-plugin-ts": "^2.13.0", + "@swc/core": "1.13.3", + "@tailwindcss/postcss": "^4.0.0", + "@tailwindcss/typography": "^0.5.16", + "@types/node": "^22.19.13", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/webpack": "^5.28.5", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "concurrently": "^9.1.2", + "copy-webpack-plugin": "^14.0.0", + "cross-env": "^7.0.3", + "css-loader": "^6.11.0", + "escape-string-regexp": "^5.0.0", + "eslint": "^8.57.1", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-prettier": "^9.0.0", + "eslint-import-resolver-typescript": "^3.8.3", + "eslint-plugin-compat": "^4.2.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-no-null": "^1.0.2", + "eslint-plugin-no-type-assertion": "^1.3.0", + "eslint-plugin-prettier": "^5.5.5", + "eslint-plugin-promise": "^6.6.0", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react-hooks": "^5.0.0", + "glob": "^10.5.0", + "lucide-react": "^1.8.0", + "papi-dts": "file:../paranext-core/lib/papi-dts", + "platform-bible-react": "file:../paranext-core/lib/platform-bible-react", + "postcss": "^8.5.3", + "postcss-loader": "^8.1.1", + "prettier": "^3.5.2", + "prettier-plugin-jsdoc": "^1.3.2", + "sass": "^1.85.0", + "sass-loader": "^16.0.5", + "shadcn": "^4.3.0", + "stylelint": "^16.17.0", + "stylelint-config-recommended": "^14.0.1", + "stylelint-config-sass-guidelines": "^12.1.0", + "swc-loader": "^0.2.6", + "tailwindcss": "^4.0.0", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "tsconfig-paths-webpack-plugin": "^4.2.0", + "tw-animate-css": "^1.4.0", + "typescript": "^5.8.3", + "webpack": "^5.105.2", + "webpack-cli": "^5.1.4", + "webpack-merge": "^6.0.1", + "zip-build": "^1.8.0" + }, + "overrides": { + "eslint-config-airbnb": { + "eslint-plugin-react-hooks": "$eslint-plugin-react-hooks" + } + }, + "volta": { + "node": "22.22.0" + } +} diff --git a/scripts/refresh-template-baseline.cjs b/scripts/refresh-template-baseline.cjs new file mode 100644 index 00000000..ca2b84f1 --- /dev/null +++ b/scripts/refresh-template-baseline.cjs @@ -0,0 +1,120 @@ +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { fail } = require('./report-failure.cjs'); + +/** + * Copies the template's `package.json` over the baseline `npm run lint:dependencies` compares + * against, and records the commit it came from in {@link CHECK_SCRIPT_PATH}. README's update + * instructions run this in the same commit that merges the template. + * + * Writing both is what holds them together. The copy is what the check reads and the commit is only + * what its output names, so nothing downstream would notice them disagreeing: a refresh that moved + * one and left the other would leave every run naming a template state it had not compared + * against. + */ + +const REPO_ROOT = path.join(__dirname, '..'); +const BASELINE_PATH = path.join(__dirname, 'merged-template-package.json'); +const CHECK_SCRIPT_PATH = path.join(__dirname, 'check-dependency-scope.cjs'); + +/** The template state to copy from — a remote-tracking ref, so it moves only on `git fetch`. */ +const TEMPLATE_REF = 'template/main'; + +/** + * The recorded commit's assignment in {@link CHECK_SCRIPT_PATH}, matched whole so the rewrite cannot + * land on another hex run in the file — substituting in the wrong place is the one failure this + * script would still report as a success. The id is matched at either git object-format width, so + * what one run writes the next can still find. + */ +const RECORDED_COMMIT_ASSIGNMENT = /^(const MERGED_TEMPLATE_COMMIT = ')([0-9a-f]{40,64})(';)/m; + +/** + * Writes one half of the refresh, reporting a failure as the state it leaves behind. Left to Node's + * default handler it arrives as a stack trace, which says that a write failed but not which half of + * the pair had already landed — the one disagreement nothing downstream reports. + */ +function writeOrFail(filePath, contents, hint) { + try { + fs.writeFileSync(filePath, contents); + } catch (error) { + fail(`Could not write ${path.relative(REPO_ROOT, filePath)}: ${error.message}`, hint); + } +} + +/** + * Runs git in the repo root and returns its stdout. + * + * @throws When git exits non-zero, carrying git's own stderr as the message. + */ +function git(...args) { + try { + return execFileSync('git', args, { + cwd: REPO_ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + throw new Error(error.stderr?.trim() || error.message); + } +} + +let commit; +try { + commit = git('rev-parse', '--verify', TEMPLATE_REF).trim(); +} catch (error) { + fail( + `Could not resolve ${TEMPLATE_REF}: ${error.message}`, + 'Adding the template remote is a one-time step after cloning, and its refs need fetching before a merge. README\'s "To update this extension from the template" section has both commands.', + ); +} + +const shortCommit = commit.slice(0, 7); + +let manifest; +try { + manifest = git('show', `${commit}:package.json`); +} catch (error) { + fail( + `Could not read package.json from ${TEMPLATE_REF} at ${shortCommit}: ${error.message}`, + `A commit carrying no package.json is not a template commit, so check where the last fetch left ${TEMPLATE_REF}.`, + ); +} + +let checkScript; +try { + checkScript = fs.readFileSync(CHECK_SCRIPT_PATH, 'utf8'); +} catch (error) { + fail( + `Could not read ${path.relative(REPO_ROOT, CHECK_SCRIPT_PATH)}: ${error.message}`, + 'The baseline and the commit recorded beside it are refreshed together, so this needs both files in place.', + ); +} + +const recordedAssignment = checkScript.match(RECORDED_COMMIT_ASSIGNMENT); +if (!recordedAssignment) + fail( + `Found no MERGED_TEMPLATE_COMMIT assignment to rewrite in ${path.relative(REPO_ROOT, CHECK_SCRIPT_PATH)}`, + "The rewrite expects that constant to be a commit id assigned on one line, as in `const MERGED_TEMPLATE_COMMIT = '…';`. Restore that shape, or teach this script the new one.", + ); + +// Ordering is load-bearing: every read above has to succeed before either write below happens, so a +// read that fails leaves the baseline and the recorded commit as they were, and as each other. The +// writes cannot be given that same guarantee. +writeOrFail( + BASELINE_PATH, + manifest, + 'Nothing was written, so the baseline and the recorded commit are still as they were and still in step. Re-running once the write can succeed is the whole repair.', +); +writeOrFail( + CHECK_SCRIPT_PATH, + checkScript.replace(RECORDED_COMMIT_ASSIGNMENT, `$1${commit}$3`), + `Half of the refresh landed: ${path.relative(REPO_ROOT, BASELINE_PATH)} now holds the package.json from ${shortCommit}, while MERGED_TEMPLATE_COMMIT still records ${recordedAssignment[2].slice(0, 7)}. Re-run once the write can succeed, or check out ${path.relative(REPO_ROOT, BASELINE_PATH)} again to put the pair back in step.`, +); + +console.log( + `✓ Copied package.json from ${TEMPLATE_REF} at ${shortCommit} into ${path.relative(REPO_ROOT, BASELINE_PATH)}`, +); +console.log( + `✓ Recorded ${shortCommit} as MERGED_TEMPLATE_COMMIT in ${path.relative(REPO_ROOT, CHECK_SCRIPT_PATH)}`, +); diff --git a/scripts/report-failure.cjs b/scripts/report-failure.cjs new file mode 100644 index 00000000..06c1cb12 --- /dev/null +++ b/scripts/report-failure.cjs @@ -0,0 +1,21 @@ +const fs = require('fs'); + +/** + * Reports a multi-line failure and stops. + * + * @param lines - Written as given, so each carries its own ✗ or ℹ marker. + */ +function failWith(lines) { + // Written synchronously because `process.exit` does not wait for an asynchronous write, and + // stderr is asynchronous on a Windows terminal and, by contract, on a POSIX pipe — which is what + // it is whenever CI captures a run. A report cut short names fewer problems than were found. + fs.writeSync(2, `${lines.join('\n')}\n`); + process.exit(1); +} + +/** Reports a failure and stops: what went wrong, then what to do about it. */ +function fail(reason, hint) { + failWith([`✗ ${reason}`, `ℹ ${hint}`]); +} + +module.exports = { fail, failWith };