Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 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. `npm run
# lint:dependencies` checks that.
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: monthly
# The three `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:
# Test tooling moves together, so one PR per cycle rather than one per
# package. Major bumps stay ungrouped: those are worth reading on their own.
test-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
- 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:
- '*'
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -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 refreshing it is a plain
# `git show` redirect rather than a copy plus whatever reformatting our config would impose
scripts/merged-template-package.json
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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` and point `MERGED_TEMPLATE_COMMIT` in [scripts/check-dependency-scope.cjs](scripts/check-dependency-scope.cjs) at the merged commit. [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.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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). 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.
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
223 changes: 223 additions & 0 deletions scripts/check-dependency-scope.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const { fail } = 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`. 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 failure 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 UNREADABLE_DEPENDABOT_CONFIG_HINT =
'The allow and ignore lists this check holds package.json to live in that file, so there is nothing to check until it is readable.';

/**
* 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.
*/
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 readDependencies(manifestPath) {
try {
return collectDependencies(JSON.parse(fs.readFileSync(manifestPath, 'utf8')));
} catch (error) {
throw new Error(`Could not read ${path.relative(REPO_ROOT, manifestPath)}: ${error.message}`);
}
}

/**
* The dependency names the npm ecosystem entry allows and ignores.
*
* @throws When the config is missing or is not YAML, and when it declares no npm ecosystem, which
* would otherwise read as an empty scope that passes every check.
*/
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 npmEntry = config?.updates?.find((entry) => entry['package-ecosystem'] === 'npm');
if (!npmEntry) throw new Error(`No npm ecosystem entry in ${configPath}`);

const dependencyNames = (entries) => (entries ?? []).map((entry) => entry['dependency-name']);
return { allow: dependencyNames(npmEntry.allow), ignore: dependencyNames(npmEntry.ignore) };
}

/** @returns {string[]} One line per violation; empty when the scoping rule holds. */
function findViolations(ours, template, scope) {
const violations = [];
const recordedByName = new Map(RECORDED_RANGE_DIVERGENCES.map((entry) => [entry.name, entry]));

RECORDED_RANGE_DIVERGENCES.forEach((recorded) => {
if (ours[recorded.name] === recorded.ours && template[recorded.name] === recorded.template)
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 ${template[recorded.name] ?? '(absent)'} against ours ${ours[recorded.name] ?? '(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;
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 templateDependencies = readOrFail(
() => readDependencies(MERGED_TEMPLATE_MANIFEST_PATH),
UNREADABLE_BASELINE_HINT,
);
const ours = readOrFail(() => readDependencies(OUR_MANIFEST_PATH), UNREADABLE_MANIFEST_HINT);
const scope = readOrFail(readNpmScope, UNREADABLE_DEPENDABOT_CONFIG_HINT);

const violations = findViolations(ours, templateDependencies, scope);

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) {
violations.forEach((violation) => console.error(`✗ ${violation}`));
console.error(`ℹ ${STALE_BASELINE_HINT}`);
process.exit(1);
}

console.log('✓ Dependabot scope matches the packages this extension adds to the template');
process.exit(0);
Loading
Loading