citesync 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
citesync-0.1.0/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2026 Simon van Lierde
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
citesync-0.1.0/NOTICE ADDED
@@ -0,0 +1,6 @@
1
+ citesync
2
+ Copyright 2026 Simon van Lierde
3
+
4
+ src/citesync/mapping-local.json is derived from the CodeMeta crosswalks
5
+ (https://github.com/codemeta/codemeta, Apache License 2.0) by way of
6
+ codemeta-drift (https://github.com/simonvanlierde/codemeta-drift, Apache License 2.0).
@@ -0,0 +1,213 @@
1
+ Metadata-Version: 2.4
2
+ Name: citesync
3
+ Version: 0.1.0
4
+ Summary: Check that CITATION.cff, codemeta.json, .zenodo.json and the package manifests (pyproject.toml, Cargo.toml, package.json) agree, and rewrite them to match one source file, using the CodeMeta crosswalks
5
+ Keywords: citation,CITATION.cff,codemeta,zenodo,software-metadata,pre-commit
6
+ Author: Simon van Lierde
7
+ Author-email: Simon van Lierde <code@duinlab.nl>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ License-File: NOTICE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Quality Assurance
20
+ Classifier: Typing :: Typed
21
+ Requires-Dist: packaging>=26.3
22
+ Requires-Dist: ruamel-yaml>=0.19
23
+ Requires-Dist: tomlkit>=0.15.2
24
+ Requires-Python: >=3.12
25
+ Project-URL: Homepage, https://github.com/simonvanlierde/citesync
26
+ Project-URL: Issues, https://github.com/simonvanlierde/citesync/issues
27
+ Project-URL: Changelog, https://github.com/simonvanlierde/citesync/blob/main/CHANGELOG.md
28
+ Description-Content-Type: text/markdown
29
+
30
+ # citesync
31
+
32
+ [![python](https://github.com/simonvanlierde/citesync/actions/workflows/python.yml/badge.svg)](https://github.com/simonvanlierde/citesync/actions/workflows/python.yml)
33
+ [![PyPI](https://img.shields.io/pypi/v/citesync)](https://pypi.org/project/citesync/)
34
+ [![Python versions](https://img.shields.io/pypi/pyversions/citesync)](https://pypi.org/project/citesync/)
35
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/simonvanlierde/citesync/badge)](https://scorecard.dev/viewer/?uri=github.com/simonvanlierde/citesync)
36
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/simonvanlierde/citesync/blob/main/LICENSE)
37
+
38
+ Status: experimental. It may change in ways that break your code, without notice.
39
+
40
+ Questions and bugs: see [CONTRIBUTING.md](https://github.com/simonvanlierde/citesync/blob/main/.github/CONTRIBUTING.md).
41
+
42
+ Software citation metadata: citesync checks that CITATION.cff, codemeta.json, .zenodo.json and the package manifests (pyproject.toml, Cargo.toml, package.json) say the same thing. Where each field sits in each file comes from a mapping of CodeMeta's crosswalks, exported by [codemeta-drift](https://github.com/simonvanlierde/codemeta-drift).
43
+
44
+ ## What works now
45
+
46
+ `citesync check` compares eight fields with one file, the source (CITATION.cff by default): title/name, version, authors with ORCID, licence, DOI, repository URL, description and keywords.
47
+
48
+ ```sh
49
+ uv tool install citesync # or run it once with uvx citesync check
50
+ citesync check # exit 0: they agree; 1: an error finding; 2: citesync couldn't run
51
+ citesync check --source pyproject.toml
52
+ citesync check --root path/to/repo # check another directory
53
+ citesync check --format json # or --json; also --format github for workflow annotations
54
+ citesync check -v # also list fields that weren't compared (not-checked)
55
+ citesync check -q # only errors, no summary line
56
+ citesync rules # the rules, with their severity; --format json too
57
+ citesync explain version-mismatch # what a rule checks and how to fix it
58
+ citesync config # the config in use, and the file it came from
59
+ citesync write # rewrite the other files to agree with the source
60
+ citesync write --diff # print what write would change, and change nothing
61
+ citesync --version
62
+ ```
63
+
64
+ Run from a subdirectory, citesync finds the repo root as ruff does: it walks up to the nearest directory with a citation file or a citesync config, and stops at the git root, so a submodule keeps its own files. File paths in findings are relative to that root.
65
+
66
+ Each finding is one line, `file: severity[rule] field: message`, and a last line counts them:
67
+
68
+ ```text
69
+ .zenodo.json:2:1: error[unreadable] Expecting property name enclosed in double quotes
70
+ codemeta.json: error[version-mismatch] version: '2.0.0', CITATION.cff has '1.0.0'
71
+ 2 errors, 0 warnings in 3 files
72
+ ```
73
+
74
+ The severity is in colour on a terminal. `NO_COLOR` turns colour off, and `FORCE_COLOR` turns it on anywhere (`FORCE_COLOR=0` or `false` turns it off). `NO_COLOR` wins.
75
+
76
+ The rule ids name the field: `name-mismatch`, `version-mismatch`, `author-mismatch`, `license-mismatch`, `doi-mismatch`, `repository-mismatch`, `description-mismatch` and `keywords-mismatch` when a field differs from the source; `keywords-not-subset` for a manifest keyword the citation file doesn't have; `context-mismatch` when codemeta.json's `@context` isn't the configured CodeMeta version; `role-dangling` (warning) for a CodeMeta Role in codemeta.json whose person, named by ORCID, isn't listed; `unreadable` for a file citesync can't read; and `not-checked` (info) for a field that wasn't compared. [docs/rules.md](https://github.com/simonvanlierde/citesync/blob/main/docs/rules.md) explains each one. When the source has no place or no value for a field (pyproject.toml has no DOI), the other files that hold it must agree with each other.
77
+
78
+ A file citesync can't read or parse is an `unreadable` finding, with the parser's message and its line and column when the parser gives them. The other files are still checked against each other, and when the unreadable file is the source, each field it holds is reported as `not-checked`.
79
+
80
+ | Exit | When |
81
+ | ---: | --- |
82
+ | 0 | no error finding |
83
+ | 1 | an error finding, an unreadable file included |
84
+ | 2 | citesync couldn't run: a bad config or option, a missing source file, or an internal error. stdout is empty and stderr has one `citesync: <message>` line |
85
+
86
+ `--format json` prints one document:
87
+
88
+ ```json
89
+ {
90
+ "version": 1,
91
+ "tool": { "name": "citesync", "version": "0.1.0" },
92
+ "summary": { "files": 3, "errors": 1, "warnings": 0, "info": 0 },
93
+ "findings": [
94
+ {
95
+ "rule": "version-mismatch",
96
+ "severity": "error",
97
+ "file": "codemeta.json",
98
+ "term": "version",
99
+ "message": "'2.0.0', CITATION.cff has '1.0.0'",
100
+ "help": "Make the file agree with the source, or the other way round if the source is wrong.",
101
+ "url": "https://github.com/simonvanlierde/citesync/blob/main/docs/rules.md#version-mismatch",
102
+ "line": null,
103
+ "column": null
104
+ }
105
+ ]
106
+ }
107
+ ```
108
+
109
+ Findings are sorted by file, rule and field, and positions count from 1. A value longer than 80 characters is cut to 77 and ends in `...` in the message. `file` is null for a field no file holds, and `term` (the CodeMeta term) is null for `unreadable`. JSON always lists every finding: `-q` and `-v` change only the text and GitHub output. [tests/output.schema.json](https://github.com/simonvanlierde/citesync/blob/main/tests/output.schema.json) is the JSON Schema. A minor release may add rule ids and optional fields; ignore what you don't know. Removing or retyping a field bumps `version`, in a major release. `--format github` writes one workflow annotation per finding, titled `citesync <rule>`, and ends with a notice that counts them all, since GitHub shows only the first ten of each level.
110
+
111
+ Spellings are normalised before comparing: SPDX ids, SPDX URLs and Zenodo's lowercase ids; ORCID URLs and bare iDs; Zenodo's `"Family, Given"` and npm or Cargo `"Name <email>"`; DOIs with or without `https://doi.org/`, and a codemeta `identifier` given as a schema.org PropertyValue (`propertyID` `DOI`, or a DOI as its `value` or `url`; other PropertyValues, such as a SWHID, are skipped); repository URLs as npm's hosted-git-info reads them: `git+`, `.git`, `http://`, `www.`, the host's case, npm shorthands (`github:owner/repo`, `owner/repo`), SSH remotes, and a branch, tag or file page on GitHub, GitLab, Codeberg, Gitea or Bitbucket (`…/tree/v1.0`, as Zenodo's GitHub integration writes), with GitHub paths in any case; CFF `name-particle` and `name-suffix`, read as part of the family name; a licence list, read as `A OR B`; PEP 621 url labels as packaging normalises them (`Source Code`, `homepage`); versions as PEP 440 versions (`v1.2.3` = `1.2.3`, `1.0` = `1.0.0`, `0.3.1-rc.1` = `0.3.1rc1`, `1.0.0-dev.1` = `1.0.0.dev1`), while a version that isn't one (`unreleased`) must match exactly. Names and keywords compare case-insensitively, with whitespace collapsed and Unicode in NFC; set `keywords-case-sensitive = true` to compare keywords by case too. Findings show each file's own spelling. Manifests are compared on version, authors' names, licence, repository URL and keywords, not on name or description. npm's one `author` is compared with the first author.
112
+
113
+ By default it reads every citation file present and the release manifests at the repo root: a pyproject.toml without `[build-system]` and a private package.json are skipped, a field in pyproject's `dynamic` is skipped on its own, and a Cargo workspace is read through `[workspace.package]`. The tests run each of the six files as the source against a fixture where all six agree, and CITATION.cff as the source against five real repositories.
114
+
115
+ `citesync write` fixes what `check` reports, and nothing more. `citesync write --diff` prints the same edits as a unified diff and writes nothing; it exits 1 when there is a change, or a file it can't read (listed on stderr), so CI can run it. It writes every citation file that isn't the source, and a manifest only for the fields listed in `write-manifests` (or all of them with `"all"`). A field a file leaves out stays out: a .zenodo.json without a version never gains one. When the source has no value for a field (no DOI in pyproject.toml), write leaves that field alone in the other files. It keeps each file's layout: comments, key order and quoting in CITATION.cff, comments in TOML, and in JSON the key order, indentation, one-line arrays and final newline. Each value is spelled the way that file spells it (Zenodo's `"Family, Given"` and lowercase licence ids, a DOI URL in codemeta.json, where a schema.org PropertyValue keeps its other keys, semver in Cargo.toml and package.json, PEP 440 in pyproject.toml). A value the file can't spell is left as it was, and its finding says why: a compound licence in .zenodo.json, a licence with `AND` or `WITH` in CITATION.cff or codemeta.json (`OR` becomes a list), in Cargo.toml and package.json a version semver has no spelling for (a post-release such as `1.2.3.post1`, an epoch, more than three numbers), and in pyproject.toml a version that isn't PEP 440. An author with only a name, such as an organization (`name` in CITATION.cff and codemeta.json), counts as a family name and keeps it. An author whose name or ORCID matches keeps the rest of their entry, such as an email or affiliation; anyone else gets a new entry. When that would drop an old entry holding more than a name and ORCID, such as after a renaming in a manifest, write leaves that file's authors for you to edit. A Cargo field inherited with `.workspace = true` is left as it is: change it in the workspace's `[workspace.package]`.
116
+
117
+ Values that compare equal stay as written: write leaves `v1.2.3` against `1.2.3`, and `LCA` against `lca` unless `keywords-case-sensitive` is set. A finding whose rule `ignore` lists is not written, and an `unreadable` file is never written. It prints `wrote <file>` for each file it changed (to stderr with `--format json` or `github`, and not at all with `--quiet`), then reports the findings left as `check` does, in the same formats, each with what to do: edit it by hand (with the reason, when write could have written it but didn't), or add the field to `write-manifests`. Exit 0 when no error finding remains, 1 when one does (an unreadable file included), and 2 when citesync couldn't run. Every file is written to a temporary file beside it before any is moved into place, so a failure leaves the files as they were, unless a move itself fails, which leaves the files moved before it written. A symlinked file is written through the link, CRLF line ends stay CRLF, and a file that would not change is not touched.
118
+
119
+ Known limits of the CITATION.cff round trip: an end-of-line comment aligned with spaces after a value (`title: x # c`) is reflowed, a second sequence indented differently from the first is reindented to match it, and a closing `...` and a byte-order mark are dropped.
120
+
121
+ Config goes in the first of `citesync.toml`, `.citesync.toml`, `[tool.citesync]` in pyproject.toml, `[workspace.metadata.citesync]` or `[package.metadata.citesync]` in Cargo.toml, or `"citesync"` in package.json:
122
+
123
+ ```toml
124
+ source = "CITATION.cff" # any of the six files
125
+ codemeta-version = "3.0"
126
+ files = ["codemeta.json", ".zenodo.json"] # default: the citation files present
127
+ manifests = ["src/core/package.json"] # replaces the default root manifests
128
+ write-manifests = ["keywords", "license"] # or "all"; the manifest fields write may change
129
+ keywords-case-sensitive = true # default false: keywords compare as names do
130
+ ignore = ["description-mismatch"] # rule ids whose findings to drop
131
+ ```
132
+
133
+ `write-manifests` names fields by their CodeMeta term, as the JSON `term` does: `name`, `version`, `author`, `license`, `identifier` (the DOI), `codeRepository`, `description` and `keywords`. Rule ids use the plainer `doi` and `repository`.
134
+
135
+ An `ignore` entry that matched no finding prints a warning to stderr. An unknown rule id is a config error, and `unreadable` can't be ignored.
136
+
137
+ Not built yet: creating a citation file listed under `files` that doesn't exist yet.
138
+
139
+ ## Pre-commit hook
140
+
141
+ For [prek](https://prek.j178.dev) or [pre-commit](https://pre-commit.com), in `.pre-commit-config.yaml`:
142
+
143
+ ```yaml
144
+ repos:
145
+ - repo: https://github.com/simonvanlierde/citesync
146
+ rev: v0.1.0
147
+ hooks:
148
+ - id: citesync
149
+ - id: citesync-write
150
+ ```
151
+
152
+ The hook runs `citesync check` from the repo root when a commit changes CITATION.cff, codemeta.json, .zenodo.json, pyproject.toml, Cargo.toml, package.json, `citesync.toml` or `.citesync.toml`, at any depth. It always checks every file, not only the staged ones, and fails the commit on an error finding. `citesync-write` runs `citesync write` only when you ask for it: `prek run citesync-write --stage manual --all-files`.
153
+
154
+ ## Tested on real repositories
155
+
156
+ The tests copy the metadata files of five public repositories byte for byte (commits in [tests/fixtures/SOURCES.txt](https://github.com/simonvanlierde/citesync/blob/main/tests/fixtures/SOURCES.txt)) and check that citesync reports exactly these findings, with CITATION.cff as the source.
157
+
158
+ | Repository | Config | Findings | Exit | Why |
159
+ | --- | --- | --- | ---: | --- |
160
+ | sourceledger | none | codemeta.json `context-mismatch` | 1 | codemeta.json uses the CodeMeta 4.0 context, and citesync expects 3.0 unless `codemeta-version` says otherwise. pyproject.toml and .zenodo.json agree on every field |
161
+ | credit-matrix | none | codemeta.json `name-mismatch` | 1 | codemeta.json names it "CRediT Matrix", CITATION.cff "CRediT Matrix: drafting CRediT contribution statements with co-authors". The root package.json is private, so it is skipped |
162
+ | credit-matrix | `manifests = ["src/core/package.json"]` | codemeta.json `name-mismatch` | 1 | As above. The package agrees: its version, licence and author match, its keywords are a subset, and its `git+…/credit-matrix.git` repository names the same repository |
163
+ | jsonvet | none | Cargo.toml `keywords-not-subset` | 1 | The workspace keywords include `lint`, and CITATION.cff has `linter`. Only Cargo.toml holds a version, so there is nothing to compare it with |
164
+ | jsonvet | `manifests = ["crates/jsonvet-cli/pyproject.toml"]` | that pyproject.toml `keywords-not-subset`; `not-checked` version (info) | 1 | The same keyword. The list replaces the root manifests and the pyproject version is `dynamic`, so no file holds a version |
165
+ | r9lab | none | codemeta.json `context-mismatch` | 1 | As sourceledger. The root pyproject.toml has no `[build-system]` and the root package.json is private, so no manifest is checked |
166
+ | project-templates | none | `not-checked` version (info) | 0 | CITATION.cff alone, with no version |
167
+
168
+ A made-up set where all six files agree exits 0 with each of them as the source.
169
+
170
+ ## Development
171
+
172
+ You need [uv](https://docs.astral.sh/uv/) and [just](https://just.systems).
173
+
174
+ ```sh
175
+ uv sync
176
+ uv tool install prek # once per machine
177
+ prek install # git hooks
178
+ ```
179
+
180
+ `just` lists the tasks. They mirror what [.github/workflows/](https://github.com/simonvanlierde/citesync/tree/main/.github/workflows) runs.
181
+
182
+ ```sh
183
+ just check # lint, types and tests for every stack here
184
+ just fmt # apply each stack's fixes and formatting
185
+ just hooks # the git hooks, as the checks workflow runs them
186
+ just mutation # mutation-test the comparison code (not in CI)
187
+ ```
188
+
189
+ `just mutation` changes `check.py` and `codecs.py` one small step at a time and fails if the tests
190
+ still pass on a change that `tests/mutation-baseline.txt` does not list. A change that
191
+ cannot alter behaviour goes in that file with the reason. It also fails on a listed
192
+ change the tests now catch, so the list stays true.
193
+
194
+ uv locks only releases at least a week old. If `just check` fails on
195
+ a security fix newer than that, add the package to `exclude-newer-package` under `[tool.uv]` in `pyproject.toml`,
196
+ and remove it a week later ([how](https://github.com/simonvanlierde/project-templates/blob/main/docs/tooling.md#dependencies)).
197
+
198
+ ## Releasing
199
+
200
+ To release, add a `## [X.Y.Z]` section to `CHANGELOG.md`, then push the tag `vX.Y.Z`.
201
+ `python-release.yml` publishes to PyPI, then creates the GitHub release with the distributions and their provenance bundle.
202
+ Approve the `pypi` environment when prompted. Do not create the GitHub release by hand.
203
+ If the release job fails after PyPI succeeds, delete any leftover draft with `gh release delete vX.Y.Z`.
204
+ Re-run only the `release` job. A published immutable release needs a new patch version to fix its files.
205
+
206
+ Before the first release, set up the publishing environment
207
+ (`pypi`)
208
+ in Settings > Environments: limit it to tags matching `v*`, and add a required reviewer.
209
+ The release workflows explain the rest in their header comments.
210
+
211
+ ## License
212
+
213
+ See [LICENSE](https://github.com/simonvanlierde/citesync/blob/main/LICENSE) for the license terms.
@@ -0,0 +1,184 @@
1
+ # citesync
2
+
3
+ [![python](https://github.com/simonvanlierde/citesync/actions/workflows/python.yml/badge.svg)](https://github.com/simonvanlierde/citesync/actions/workflows/python.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/citesync)](https://pypi.org/project/citesync/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/citesync)](https://pypi.org/project/citesync/)
6
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/simonvanlierde/citesync/badge)](https://scorecard.dev/viewer/?uri=github.com/simonvanlierde/citesync)
7
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/simonvanlierde/citesync/blob/main/LICENSE)
8
+
9
+ Status: experimental. It may change in ways that break your code, without notice.
10
+
11
+ Questions and bugs: see [CONTRIBUTING.md](https://github.com/simonvanlierde/citesync/blob/main/.github/CONTRIBUTING.md).
12
+
13
+ Software citation metadata: citesync checks that CITATION.cff, codemeta.json, .zenodo.json and the package manifests (pyproject.toml, Cargo.toml, package.json) say the same thing. Where each field sits in each file comes from a mapping of CodeMeta's crosswalks, exported by [codemeta-drift](https://github.com/simonvanlierde/codemeta-drift).
14
+
15
+ ## What works now
16
+
17
+ `citesync check` compares eight fields with one file, the source (CITATION.cff by default): title/name, version, authors with ORCID, licence, DOI, repository URL, description and keywords.
18
+
19
+ ```sh
20
+ uv tool install citesync # or run it once with uvx citesync check
21
+ citesync check # exit 0: they agree; 1: an error finding; 2: citesync couldn't run
22
+ citesync check --source pyproject.toml
23
+ citesync check --root path/to/repo # check another directory
24
+ citesync check --format json # or --json; also --format github for workflow annotations
25
+ citesync check -v # also list fields that weren't compared (not-checked)
26
+ citesync check -q # only errors, no summary line
27
+ citesync rules # the rules, with their severity; --format json too
28
+ citesync explain version-mismatch # what a rule checks and how to fix it
29
+ citesync config # the config in use, and the file it came from
30
+ citesync write # rewrite the other files to agree with the source
31
+ citesync write --diff # print what write would change, and change nothing
32
+ citesync --version
33
+ ```
34
+
35
+ Run from a subdirectory, citesync finds the repo root as ruff does: it walks up to the nearest directory with a citation file or a citesync config, and stops at the git root, so a submodule keeps its own files. File paths in findings are relative to that root.
36
+
37
+ Each finding is one line, `file: severity[rule] field: message`, and a last line counts them:
38
+
39
+ ```text
40
+ .zenodo.json:2:1: error[unreadable] Expecting property name enclosed in double quotes
41
+ codemeta.json: error[version-mismatch] version: '2.0.0', CITATION.cff has '1.0.0'
42
+ 2 errors, 0 warnings in 3 files
43
+ ```
44
+
45
+ The severity is in colour on a terminal. `NO_COLOR` turns colour off, and `FORCE_COLOR` turns it on anywhere (`FORCE_COLOR=0` or `false` turns it off). `NO_COLOR` wins.
46
+
47
+ The rule ids name the field: `name-mismatch`, `version-mismatch`, `author-mismatch`, `license-mismatch`, `doi-mismatch`, `repository-mismatch`, `description-mismatch` and `keywords-mismatch` when a field differs from the source; `keywords-not-subset` for a manifest keyword the citation file doesn't have; `context-mismatch` when codemeta.json's `@context` isn't the configured CodeMeta version; `role-dangling` (warning) for a CodeMeta Role in codemeta.json whose person, named by ORCID, isn't listed; `unreadable` for a file citesync can't read; and `not-checked` (info) for a field that wasn't compared. [docs/rules.md](https://github.com/simonvanlierde/citesync/blob/main/docs/rules.md) explains each one. When the source has no place or no value for a field (pyproject.toml has no DOI), the other files that hold it must agree with each other.
48
+
49
+ A file citesync can't read or parse is an `unreadable` finding, with the parser's message and its line and column when the parser gives them. The other files are still checked against each other, and when the unreadable file is the source, each field it holds is reported as `not-checked`.
50
+
51
+ | Exit | When |
52
+ | ---: | --- |
53
+ | 0 | no error finding |
54
+ | 1 | an error finding, an unreadable file included |
55
+ | 2 | citesync couldn't run: a bad config or option, a missing source file, or an internal error. stdout is empty and stderr has one `citesync: <message>` line |
56
+
57
+ `--format json` prints one document:
58
+
59
+ ```json
60
+ {
61
+ "version": 1,
62
+ "tool": { "name": "citesync", "version": "0.1.0" },
63
+ "summary": { "files": 3, "errors": 1, "warnings": 0, "info": 0 },
64
+ "findings": [
65
+ {
66
+ "rule": "version-mismatch",
67
+ "severity": "error",
68
+ "file": "codemeta.json",
69
+ "term": "version",
70
+ "message": "'2.0.0', CITATION.cff has '1.0.0'",
71
+ "help": "Make the file agree with the source, or the other way round if the source is wrong.",
72
+ "url": "https://github.com/simonvanlierde/citesync/blob/main/docs/rules.md#version-mismatch",
73
+ "line": null,
74
+ "column": null
75
+ }
76
+ ]
77
+ }
78
+ ```
79
+
80
+ Findings are sorted by file, rule and field, and positions count from 1. A value longer than 80 characters is cut to 77 and ends in `...` in the message. `file` is null for a field no file holds, and `term` (the CodeMeta term) is null for `unreadable`. JSON always lists every finding: `-q` and `-v` change only the text and GitHub output. [tests/output.schema.json](https://github.com/simonvanlierde/citesync/blob/main/tests/output.schema.json) is the JSON Schema. A minor release may add rule ids and optional fields; ignore what you don't know. Removing or retyping a field bumps `version`, in a major release. `--format github` writes one workflow annotation per finding, titled `citesync <rule>`, and ends with a notice that counts them all, since GitHub shows only the first ten of each level.
81
+
82
+ Spellings are normalised before comparing: SPDX ids, SPDX URLs and Zenodo's lowercase ids; ORCID URLs and bare iDs; Zenodo's `"Family, Given"` and npm or Cargo `"Name <email>"`; DOIs with or without `https://doi.org/`, and a codemeta `identifier` given as a schema.org PropertyValue (`propertyID` `DOI`, or a DOI as its `value` or `url`; other PropertyValues, such as a SWHID, are skipped); repository URLs as npm's hosted-git-info reads them: `git+`, `.git`, `http://`, `www.`, the host's case, npm shorthands (`github:owner/repo`, `owner/repo`), SSH remotes, and a branch, tag or file page on GitHub, GitLab, Codeberg, Gitea or Bitbucket (`…/tree/v1.0`, as Zenodo's GitHub integration writes), with GitHub paths in any case; CFF `name-particle` and `name-suffix`, read as part of the family name; a licence list, read as `A OR B`; PEP 621 url labels as packaging normalises them (`Source Code`, `homepage`); versions as PEP 440 versions (`v1.2.3` = `1.2.3`, `1.0` = `1.0.0`, `0.3.1-rc.1` = `0.3.1rc1`, `1.0.0-dev.1` = `1.0.0.dev1`), while a version that isn't one (`unreleased`) must match exactly. Names and keywords compare case-insensitively, with whitespace collapsed and Unicode in NFC; set `keywords-case-sensitive = true` to compare keywords by case too. Findings show each file's own spelling. Manifests are compared on version, authors' names, licence, repository URL and keywords, not on name or description. npm's one `author` is compared with the first author.
83
+
84
+ By default it reads every citation file present and the release manifests at the repo root: a pyproject.toml without `[build-system]` and a private package.json are skipped, a field in pyproject's `dynamic` is skipped on its own, and a Cargo workspace is read through `[workspace.package]`. The tests run each of the six files as the source against a fixture where all six agree, and CITATION.cff as the source against five real repositories.
85
+
86
+ `citesync write` fixes what `check` reports, and nothing more. `citesync write --diff` prints the same edits as a unified diff and writes nothing; it exits 1 when there is a change, or a file it can't read (listed on stderr), so CI can run it. It writes every citation file that isn't the source, and a manifest only for the fields listed in `write-manifests` (or all of them with `"all"`). A field a file leaves out stays out: a .zenodo.json without a version never gains one. When the source has no value for a field (no DOI in pyproject.toml), write leaves that field alone in the other files. It keeps each file's layout: comments, key order and quoting in CITATION.cff, comments in TOML, and in JSON the key order, indentation, one-line arrays and final newline. Each value is spelled the way that file spells it (Zenodo's `"Family, Given"` and lowercase licence ids, a DOI URL in codemeta.json, where a schema.org PropertyValue keeps its other keys, semver in Cargo.toml and package.json, PEP 440 in pyproject.toml). A value the file can't spell is left as it was, and its finding says why: a compound licence in .zenodo.json, a licence with `AND` or `WITH` in CITATION.cff or codemeta.json (`OR` becomes a list), in Cargo.toml and package.json a version semver has no spelling for (a post-release such as `1.2.3.post1`, an epoch, more than three numbers), and in pyproject.toml a version that isn't PEP 440. An author with only a name, such as an organization (`name` in CITATION.cff and codemeta.json), counts as a family name and keeps it. An author whose name or ORCID matches keeps the rest of their entry, such as an email or affiliation; anyone else gets a new entry. When that would drop an old entry holding more than a name and ORCID, such as after a renaming in a manifest, write leaves that file's authors for you to edit. A Cargo field inherited with `.workspace = true` is left as it is: change it in the workspace's `[workspace.package]`.
87
+
88
+ Values that compare equal stay as written: write leaves `v1.2.3` against `1.2.3`, and `LCA` against `lca` unless `keywords-case-sensitive` is set. A finding whose rule `ignore` lists is not written, and an `unreadable` file is never written. It prints `wrote <file>` for each file it changed (to stderr with `--format json` or `github`, and not at all with `--quiet`), then reports the findings left as `check` does, in the same formats, each with what to do: edit it by hand (with the reason, when write could have written it but didn't), or add the field to `write-manifests`. Exit 0 when no error finding remains, 1 when one does (an unreadable file included), and 2 when citesync couldn't run. Every file is written to a temporary file beside it before any is moved into place, so a failure leaves the files as they were, unless a move itself fails, which leaves the files moved before it written. A symlinked file is written through the link, CRLF line ends stay CRLF, and a file that would not change is not touched.
89
+
90
+ Known limits of the CITATION.cff round trip: an end-of-line comment aligned with spaces after a value (`title: x # c`) is reflowed, a second sequence indented differently from the first is reindented to match it, and a closing `...` and a byte-order mark are dropped.
91
+
92
+ Config goes in the first of `citesync.toml`, `.citesync.toml`, `[tool.citesync]` in pyproject.toml, `[workspace.metadata.citesync]` or `[package.metadata.citesync]` in Cargo.toml, or `"citesync"` in package.json:
93
+
94
+ ```toml
95
+ source = "CITATION.cff" # any of the six files
96
+ codemeta-version = "3.0"
97
+ files = ["codemeta.json", ".zenodo.json"] # default: the citation files present
98
+ manifests = ["src/core/package.json"] # replaces the default root manifests
99
+ write-manifests = ["keywords", "license"] # or "all"; the manifest fields write may change
100
+ keywords-case-sensitive = true # default false: keywords compare as names do
101
+ ignore = ["description-mismatch"] # rule ids whose findings to drop
102
+ ```
103
+
104
+ `write-manifests` names fields by their CodeMeta term, as the JSON `term` does: `name`, `version`, `author`, `license`, `identifier` (the DOI), `codeRepository`, `description` and `keywords`. Rule ids use the plainer `doi` and `repository`.
105
+
106
+ An `ignore` entry that matched no finding prints a warning to stderr. An unknown rule id is a config error, and `unreadable` can't be ignored.
107
+
108
+ Not built yet: creating a citation file listed under `files` that doesn't exist yet.
109
+
110
+ ## Pre-commit hook
111
+
112
+ For [prek](https://prek.j178.dev) or [pre-commit](https://pre-commit.com), in `.pre-commit-config.yaml`:
113
+
114
+ ```yaml
115
+ repos:
116
+ - repo: https://github.com/simonvanlierde/citesync
117
+ rev: v0.1.0
118
+ hooks:
119
+ - id: citesync
120
+ - id: citesync-write
121
+ ```
122
+
123
+ The hook runs `citesync check` from the repo root when a commit changes CITATION.cff, codemeta.json, .zenodo.json, pyproject.toml, Cargo.toml, package.json, `citesync.toml` or `.citesync.toml`, at any depth. It always checks every file, not only the staged ones, and fails the commit on an error finding. `citesync-write` runs `citesync write` only when you ask for it: `prek run citesync-write --stage manual --all-files`.
124
+
125
+ ## Tested on real repositories
126
+
127
+ The tests copy the metadata files of five public repositories byte for byte (commits in [tests/fixtures/SOURCES.txt](https://github.com/simonvanlierde/citesync/blob/main/tests/fixtures/SOURCES.txt)) and check that citesync reports exactly these findings, with CITATION.cff as the source.
128
+
129
+ | Repository | Config | Findings | Exit | Why |
130
+ | --- | --- | --- | ---: | --- |
131
+ | sourceledger | none | codemeta.json `context-mismatch` | 1 | codemeta.json uses the CodeMeta 4.0 context, and citesync expects 3.0 unless `codemeta-version` says otherwise. pyproject.toml and .zenodo.json agree on every field |
132
+ | credit-matrix | none | codemeta.json `name-mismatch` | 1 | codemeta.json names it "CRediT Matrix", CITATION.cff "CRediT Matrix: drafting CRediT contribution statements with co-authors". The root package.json is private, so it is skipped |
133
+ | credit-matrix | `manifests = ["src/core/package.json"]` | codemeta.json `name-mismatch` | 1 | As above. The package agrees: its version, licence and author match, its keywords are a subset, and its `git+…/credit-matrix.git` repository names the same repository |
134
+ | jsonvet | none | Cargo.toml `keywords-not-subset` | 1 | The workspace keywords include `lint`, and CITATION.cff has `linter`. Only Cargo.toml holds a version, so there is nothing to compare it with |
135
+ | jsonvet | `manifests = ["crates/jsonvet-cli/pyproject.toml"]` | that pyproject.toml `keywords-not-subset`; `not-checked` version (info) | 1 | The same keyword. The list replaces the root manifests and the pyproject version is `dynamic`, so no file holds a version |
136
+ | r9lab | none | codemeta.json `context-mismatch` | 1 | As sourceledger. The root pyproject.toml has no `[build-system]` and the root package.json is private, so no manifest is checked |
137
+ | project-templates | none | `not-checked` version (info) | 0 | CITATION.cff alone, with no version |
138
+
139
+ A made-up set where all six files agree exits 0 with each of them as the source.
140
+
141
+ ## Development
142
+
143
+ You need [uv](https://docs.astral.sh/uv/) and [just](https://just.systems).
144
+
145
+ ```sh
146
+ uv sync
147
+ uv tool install prek # once per machine
148
+ prek install # git hooks
149
+ ```
150
+
151
+ `just` lists the tasks. They mirror what [.github/workflows/](https://github.com/simonvanlierde/citesync/tree/main/.github/workflows) runs.
152
+
153
+ ```sh
154
+ just check # lint, types and tests for every stack here
155
+ just fmt # apply each stack's fixes and formatting
156
+ just hooks # the git hooks, as the checks workflow runs them
157
+ just mutation # mutation-test the comparison code (not in CI)
158
+ ```
159
+
160
+ `just mutation` changes `check.py` and `codecs.py` one small step at a time and fails if the tests
161
+ still pass on a change that `tests/mutation-baseline.txt` does not list. A change that
162
+ cannot alter behaviour goes in that file with the reason. It also fails on a listed
163
+ change the tests now catch, so the list stays true.
164
+
165
+ uv locks only releases at least a week old. If `just check` fails on
166
+ a security fix newer than that, add the package to `exclude-newer-package` under `[tool.uv]` in `pyproject.toml`,
167
+ and remove it a week later ([how](https://github.com/simonvanlierde/project-templates/blob/main/docs/tooling.md#dependencies)).
168
+
169
+ ## Releasing
170
+
171
+ To release, add a `## [X.Y.Z]` section to `CHANGELOG.md`, then push the tag `vX.Y.Z`.
172
+ `python-release.yml` publishes to PyPI, then creates the GitHub release with the distributions and their provenance bundle.
173
+ Approve the `pypi` environment when prompted. Do not create the GitHub release by hand.
174
+ If the release job fails after PyPI succeeds, delete any leftover draft with `gh release delete vX.Y.Z`.
175
+ Re-run only the `release` job. A published immutable release needs a new patch version to fix its files.
176
+
177
+ Before the first release, set up the publishing environment
178
+ (`pypi`)
179
+ in Settings > Environments: limit it to tags matching `v*`, and add a required reviewer.
180
+ The release workflows explain the rest in their header comments.
181
+
182
+ ## License
183
+
184
+ See [LICENSE](https://github.com/simonvanlierde/citesync/blob/main/LICENSE) for the license terms.