champollion 0.3.3

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.
Files changed (170) hide show
  1. package/LICENSE +133 -0
  2. package/README.md +387 -0
  3. package/bin/cli.js +278 -0
  4. package/index.js +135 -0
  5. package/lib/api-key.js +127 -0
  6. package/lib/autofix.js +432 -0
  7. package/lib/bridge/method_bridge.py +430 -0
  8. package/lib/card-source-resolution.mjs +284 -0
  9. package/lib/cards/cache.js +169 -0
  10. package/lib/cards/env.js +82 -0
  11. package/lib/cards/fetch-card-child.js +38 -0
  12. package/lib/cards/reader.js +435 -0
  13. package/lib/cards/refresh.js +111 -0
  14. package/lib/cards/remote.js +387 -0
  15. package/lib/cldf-export.mjs +540 -0
  16. package/lib/cldf-terms.mjs +62 -0
  17. package/lib/command-help.js +790 -0
  18. package/lib/commands/audit.js +49 -0
  19. package/lib/commands/card.js +454 -0
  20. package/lib/commands/doctor.js +559 -0
  21. package/lib/commands/fonts.js +489 -0
  22. package/lib/commands/help.js +91 -0
  23. package/lib/commands/init.js +1259 -0
  24. package/lib/commands/integrity.js +148 -0
  25. package/lib/commands/leaderboard.js +478 -0
  26. package/lib/commands/lint.js +30 -0
  27. package/lib/commands/models.js +177 -0
  28. package/lib/commands/plugin.js +103 -0
  29. package/lib/commands/provenance.js +45 -0
  30. package/lib/commands/recommend.js +75 -0
  31. package/lib/commands/register-corpus.js +678 -0
  32. package/lib/commands/repair-script.js +42 -0
  33. package/lib/commands/seal-corpus.js +355 -0
  34. package/lib/commands/seo.js +72 -0
  35. package/lib/commands/serve.js +147 -0
  36. package/lib/commands/status.js +265 -0
  37. package/lib/commands/submit.js +332 -0
  38. package/lib/commands/sync.js +89 -0
  39. package/lib/commands/tm.js +573 -0
  40. package/lib/commands/verify.js +39 -0
  41. package/lib/commands/watch.js +20 -0
  42. package/lib/commands/wrap.js +138 -0
  43. package/lib/commands/xliff.js +327 -0
  44. package/lib/commercial-eligibility.js +235 -0
  45. package/lib/concurrent.js +87 -0
  46. package/lib/config.js +523 -0
  47. package/lib/contamination-lane.js +76 -0
  48. package/lib/content-sync.js +731 -0
  49. package/lib/content.js +733 -0
  50. package/lib/corpus-registration.mjs +608 -0
  51. package/lib/cost-report.js +346 -0
  52. package/lib/diff.js +155 -0
  53. package/lib/docusaurus-sync.js +1256 -0
  54. package/lib/flatten.js +55 -0
  55. package/lib/format.js +954 -0
  56. package/lib/hash.js +159 -0
  57. package/lib/icu.js +473 -0
  58. package/lib/integrity.js +689 -0
  59. package/lib/license-gate.mjs +478 -0
  60. package/lib/license-identify.mjs +229 -0
  61. package/lib/lint.js +629 -0
  62. package/lib/method-manifest.js +60 -0
  63. package/lib/methods/anthropic.js +140 -0
  64. package/lib/methods/apertium.js +163 -0
  65. package/lib/methods/api.js +316 -0
  66. package/lib/methods/base.js +184 -0
  67. package/lib/methods/content-separator.js +45 -0
  68. package/lib/methods/deepl.js +426 -0
  69. package/lib/methods/direct-llm.js +586 -0
  70. package/lib/methods/external.js +332 -0
  71. package/lib/methods/fetch-with-retry.js +124 -0
  72. package/lib/methods/gemini.js +147 -0
  73. package/lib/methods/google-translate.js +402 -0
  74. package/lib/methods/http-utils.js +122 -0
  75. package/lib/methods/libretranslate.js +314 -0
  76. package/lib/methods/llm-coached.js +670 -0
  77. package/lib/methods/llm.js +592 -0
  78. package/lib/methods/local.js +76 -0
  79. package/lib/methods/microsoft-translator.js +331 -0
  80. package/lib/methods/openai.js +131 -0
  81. package/lib/methods/openrouter-client.js +327 -0
  82. package/lib/methods/openrouter-pricing.js +156 -0
  83. package/lib/methods/provider-env.js +115 -0
  84. package/lib/methods/provider-pricing.js +310 -0
  85. package/lib/methods/tilde.js +150 -0
  86. package/lib/methods/translated.js +229 -0
  87. package/lib/methods/translation-error.js +80 -0
  88. package/lib/models.js +258 -0
  89. package/lib/no-translate.js +233 -0
  90. package/lib/output.js +238 -0
  91. package/lib/pairs.js +547 -0
  92. package/lib/plugins.js +447 -0
  93. package/lib/provenance.js +323 -0
  94. package/lib/recommend.js +648 -0
  95. package/lib/registers.js +1185 -0
  96. package/lib/repair-script.js +266 -0
  97. package/lib/scripts.js +994 -0
  98. package/lib/seal.mjs +464 -0
  99. package/lib/sealed-qualifier.mjs +211 -0
  100. package/lib/security.js +59 -0
  101. package/lib/segment.js +369 -0
  102. package/lib/seo.js +275 -0
  103. package/lib/serve.js +854 -0
  104. package/lib/string-classify.js +85 -0
  105. package/lib/submit.mjs +344 -0
  106. package/lib/sync.js +969 -0
  107. package/lib/tags/bcp47.js +202 -0
  108. package/lib/tags/resolve.js +314 -0
  109. package/lib/terminology.js +111 -0
  110. package/lib/tm-seed.js +294 -0
  111. package/lib/tm.js +515 -0
  112. package/lib/translate-pair.js +197 -0
  113. package/lib/translate.js +203 -0
  114. package/lib/types.js +230 -0
  115. package/lib/validate.js +510 -0
  116. package/lib/verify.js +451 -0
  117. package/lib/watch.js +145 -0
  118. package/lib/xliff.js +184 -0
  119. package/package.json +93 -0
  120. package/shared/ATTRIBUTION.md +145 -0
  121. package/shared/CORPORA-CARDS.md +288 -0
  122. package/shared/DATA-SOVEREIGNTY.md +500 -0
  123. package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
  124. package/shared/card-lint-baseline.json +3189 -0
  125. package/shared/cards-fallback.json +1 -0
  126. package/shared/catalogue/card-config.json +6091 -0
  127. package/shared/catalogue/external-results.json +3888 -0
  128. package/shared/catalogue/gender-guidance.json +1038 -0
  129. package/shared/catalogue/method-coverage.json +1751 -0
  130. package/shared/catalogue/metric-coverage.json +170 -0
  131. package/shared/catalogue/metric-reliability.json +1 -0
  132. package/shared/catalogue/register-presets.json +3180 -0
  133. package/shared/catalogue/vitality-scales.json +55 -0
  134. package/shared/cldr-index.json +1115 -0
  135. package/shared/code-bridge.json +253 -0
  136. package/shared/corpora-cards-v1-reference.md +281 -0
  137. package/shared/curated-dictionary-flags.json +35 -0
  138. package/shared/curated-endonyms.json +35 -0
  139. package/shared/curated-fsts.json +51 -0
  140. package/shared/curated-orthography-conventions.json +26 -0
  141. package/shared/curated-sil-resources.json +374 -0
  142. package/shared/curated-tools.json +41 -0
  143. package/shared/docent/corpus.json +11333 -0
  144. package/shared/docent/faq.en.json +564 -0
  145. package/shared/docent/register-blocks.json +60 -0
  146. package/shared/docent/system-prompt.md +144 -0
  147. package/shared/domain-taxonomy.json +35 -0
  148. package/shared/explainers/glossary.json +2975 -0
  149. package/shared/explainers/tc-features.json +20112 -0
  150. package/shared/explainers/term-watchlist.json +147 -0
  151. package/shared/human-services.json +59 -0
  152. package/shared/license-corrections.json +261 -0
  153. package/shared/license-evidence.json +13452 -0
  154. package/shared/licenses.json +6781 -0
  155. package/shared/method-registry.json +236 -0
  156. package/shared/metric-registry.json +620 -0
  157. package/shared/model-aliases.json +7 -0
  158. package/shared/schemas/champollion-plugin.schema.json +206 -0
  159. package/shared/schemas/corpora-card.schema.json +957 -0
  160. package/shared/schemas/domain-taxonomy.schema.json +64 -0
  161. package/shared/schemas/external-results.schema.json +314 -0
  162. package/shared/schemas/human-services.schema.json +90 -0
  163. package/shared/schemas/language-card.schema.json +1308 -0
  164. package/shared/schemas/licenses.schema.json +155 -0
  165. package/shared/schemas/method-card.schema.json +412 -0
  166. package/shared/schemas/method-registry.schema.json +85 -0
  167. package/shared/schemas/metric-registry.schema.json +96 -0
  168. package/shared/schemas/metric-reliability.schema.json +178 -0
  169. package/shared/schemas/model-aliases.schema.json +27 -0
  170. package/shared/schemas/source-snapshot.schema.json +96 -0
@@ -0,0 +1,478 @@
1
+ /**
2
+ * license-gate.mjs — one place that answers "may we use this source's data in a
3
+ * COMMERCIAL or REDISTRIBUTED context?"
4
+ *
5
+ * Champollion is an open, non-commercial aggregator of public linguistic data:
6
+ * in that lane every source may be used WITH attribution (NonCommercial licenses
7
+ * explicitly permit non-commercial use). This module is NOT about that lane — it
8
+ * exists to protect the two narrow lanes where licenses actually bite:
9
+ *
10
+ * 1. the future paid routing API (commercial use), and
11
+ * 2. any bulk redistribution / re-publication of the dataset.
12
+ *
13
+ * It classifies each `facts.source` into a license tier and exposes booleans the
14
+ * exporter (and, later, the API) use to exclude restricted data BY DEFAULT.
15
+ * Fail-safe: an unknown / unstated / mixed source is treated as RESTRICTED.
16
+ *
17
+ * USE-BASED eligibility (isUsageAllowed): "prize", "API", and "commercial" are
18
+ * ORTHOGONAL attributes, not one bucket. A NonCommercial license gates
19
+ * COMMERCIAL use + redistribution specifically — it does NOT gate non-commercial
20
+ * use. So a non-commercial prize MAY use an NC dataset; a commercial / API use
21
+ * may NOT. Callers pass a use descriptor (use_context + redistribution) and the
22
+ * gate answers from the descriptor + the license tier. (By design.)
23
+ *
24
+ * License data is read from the monorepo register `shared/licenses.json`
25
+ * (generated from the facts DB) with `shared/license-corrections.json` overlaid
26
+ * (Champollion's resolved determinations — see that file + docs/LICENSING.md).
27
+ *
28
+ * @module license-gate
29
+ */
30
+
31
+ import { readFileSync, existsSync } from 'fs';
32
+ import { join, dirname } from 'path';
33
+ import { fileURLToPath } from 'url';
34
+
35
+ const __dirname = dirname(fileURLToPath(import.meta.url));
36
+ // Resolve the license register from the BUNDLED copy first (cli/shared/, present
37
+ // in the published npm package and in the monorepo after `npm run sync:shared`),
38
+ // then fall back to the monorepo-root SSOT (../../shared) for an unsynced dev
39
+ // checkout. The package layout is champollion/{lib,shared}/, so from lib/ the
40
+ // bundled register is one level up; in the monorepo it is two (cli/lib → root).
41
+ const BUNDLED_SHARED = join(__dirname, '..', 'shared');
42
+ const ROOT_SHARED = join(__dirname, '..', '..', 'shared');
43
+ const SHARED = existsSync(join(BUNDLED_SHARED, 'licenses.json')) ? BUNDLED_SHARED : ROOT_SHARED;
44
+ const LICENSES_PATH = join(SHARED, 'licenses.json');
45
+ const CORRECTIONS_PATH = join(SHARED, 'license-corrections.json');
46
+ // The upstream evidence chain — outranks both layers above. See loadLicenseRegister().
47
+ const EVIDENCE_PATH = join(SHARED, 'license-evidence.json');
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // TIERS — the result of classifying a license. Only 'permissive' is safe for
51
+ // the commercial + redistribution lanes; everything else is restricted there.
52
+ // ---------------------------------------------------------------------------
53
+ export const TIERS = {
54
+ PERMISSIVE: 'permissive', // CC-BY / CC0 / MIT / Apache / Unicode / public-domain facts
55
+ NONCOMMERCIAL: 'noncommercial', // CC-BY-NC* — open project only
56
+ SHAREALIKE: 'sharealike', // CC-BY-SA / GPL / AGPL — copyleft
57
+ NODERIVATIVES: 'noderivatives', // *-ND
58
+ RESTRICTED_NOREDIST: 'restricted-noredist', // e.g. SIL ISO 639-3 terms (use ok, no table redistribution)
59
+ MIXED: 'mixed', // per-item licenses (OPUS/PARADISEC/ELAR/HF) — metadata only
60
+ UNSTATED: 'unstated', // looked, no public license found
61
+ DERIVED: 'derived', // champollion-derived — inherits its upstream(s)
62
+ UNKNOWN: 'unknown', // not in the register at all
63
+ };
64
+
65
+ const PERMISSIVE_EXACT = new Set([
66
+ 'LICENSEREF-CHAMPOLLION-OWN', 'LICENSEREF-FACTUALDATA', 'LICENSEREF-IANA',
67
+ 'LICENSEREF-PUBLICDOMAIN', 'MIT',
68
+ ]);
69
+
70
+ /** Is this SPDX string a permissive, freely-redistributable license (attribution at most)? */
71
+ export function isPermissiveSpdx(spdx) {
72
+ if (!spdx) return false;
73
+ const s = String(spdx).toUpperCase();
74
+ // Anything restrictive disqualifies, regardless of the CC-BY prefix.
75
+ if (s.includes('-NC') || s.includes('-SA') || s.includes('-ND')) return false;
76
+ if (s.includes('GPL')) return false; // copyleft
77
+ if (/UNCONFIRMED|UNSTATED|NO-PUBLIC|MIXED|SIL-TERMS|PROPRIETARY/.test(s)) return false;
78
+ if (s.startsWith('CC-BY')) return true; // CC-BY-2.0/3.0/4.0
79
+ if (s.startsWith('CC0')) return true;
80
+ if (s.startsWith('APACHE')) return true;
81
+ if (s.startsWith('UNICODE')) return true;
82
+ if (s.startsWith('BSD')) return true;
83
+ if (s === 'CC') return false; // bare "CC" = unspecified version → unsafe
84
+ return PERMISSIVE_EXACT.has(s);
85
+ }
86
+
87
+ /**
88
+ * Classify a single license record (the per-source object from the register).
89
+ * Pure — no I/O. Returns { tier, commercialSafe, redistributable, label, ... }.
90
+ */
91
+ export function classifyLicense(record) {
92
+ if (!record) {
93
+ return {
94
+ tier: TIERS.UNKNOWN, commercialSafe: false, redistributable: false,
95
+ nonCommercialUseAllowed: false, license_spdx: null,
96
+ };
97
+ }
98
+ const spdx = record.license_spdx || null;
99
+ const s = String(spdx || '').toUpperCase();
100
+ const nc = !!record.non_commercial_only;
101
+ const sa = !!record.requires_sharealike || s.includes('-SA') || s.includes('GPL');
102
+ const redistAllowed = record.allows_redistribution !== 0 && record.allows_redistribution !== false;
103
+
104
+ // Tier by the SPDX string first (most specific), then fall back to the raw
105
+ // non_commercial_only flag, then 'unknown'. This keeps an license-unstated
106
+ // source labelled 'unstated' even though we also flag it NC for safety.
107
+ let tier;
108
+ if (s === 'LICENSEREF-CHAMPOLLION-DERIVED') tier = TIERS.DERIVED;
109
+ else if (s.includes('-NC')) tier = TIERS.NONCOMMERCIAL;
110
+ else if (s.includes('-ND')) tier = TIERS.NODERIVATIVES;
111
+ else if (sa) tier = TIERS.SHAREALIKE;
112
+ else if (s.includes('SIL-TERMS')) tier = TIERS.RESTRICTED_NOREDIST;
113
+ // Two founder rulings, 2026-08-01. Both mean the same thing mechanically —
114
+ // we may STATE what we derived, we may not REDISTRIBUTE the source — which is
115
+ // exactly RESTRICTED_NOREDIST. They are named separately because the
116
+ // reasoning differs and a reader deserves to see which applies.
117
+ //
118
+ // ARCHIVE-CATALOGUE-COUNTS — a count of items held by a public archive
119
+ // catalogue (OLAC, AILLA, PARADISEC, Kaipuleohone) is a statable fact.
120
+ // We store "N documented items exist here", never a document.
121
+ // CITATION-ONLY — a fact may be stated with its source cited, even when the
122
+ // source is a copyrighted book we hold no licence to. No text from the
123
+ // work is reproduced. Replaced two pseudo-licences that had asserted
124
+ // allows_redistribution = 1 over copyrighted works.
125
+ //
126
+ // Before these rulings both fell through to UNKNOWN, where the audit kept
127
+ // reporting them as awaiting a determination that had already been made.
128
+ else if (s.includes('ARCHIVE-CATALOGUE-COUNTS') || s.includes('CITATION-ONLY')) {
129
+ tier = TIERS.RESTRICTED_NOREDIST;
130
+ }
131
+ // NOTE: PER-DATASET / PER-WORDNET are deliberately NOT mapped here.
132
+ //
133
+ // They were briefly folded into MIXED (commit 82b7ec7c0) so the audit would
134
+ // stop calling them "unlicensed". That was papering over the hole. An
135
+ // umbrella id saying "terms are per-dataset" is not a licence — it is a
136
+ // POINTER to 143 member datasets, 142 of which already have their own
137
+ // register entry with a real SPDX id. Labelling the pointer MIXED made the
138
+ // report tidy while leaving 1,293 card-citations of NC / ND / GPL /
139
+ // UNCONFIRMED members stamped with one permissive-looking umbrella.
140
+ //
141
+ // They now fall through to UNKNOWN — fail-safe, and visibly unresolved —
142
+ // and resolveUmbrella() below expands them to the members that actually
143
+ // carry the terms. See cli/scripts/audit-license-gate.mjs.
144
+ else if (s.includes('MIXED')) tier = TIERS.MIXED;
145
+ else if (/UNCONFIRMED|UNSTATED|NO-PUBLIC/.test(s) || s === 'CC') tier = TIERS.UNSTATED;
146
+ else if (isPermissiveSpdx(spdx)) tier = TIERS.PERMISSIVE;
147
+ else if (nc) tier = TIERS.NONCOMMERCIAL; // backstop: flagged NC without an -NC SPDX
148
+ else tier = TIERS.UNKNOWN;
149
+
150
+ const permissive = tier === TIERS.PERMISSIVE;
151
+ return {
152
+ tier,
153
+ license_spdx: spdx,
154
+ license_url: record.license_url || null,
155
+ attribution: record.attribution || null,
156
+ requiresAttribution: !!record.requires_attribution,
157
+ // Only clearly-permissive data is safe to sell or to bulk-redistribute.
158
+ commercialSafe: permissive,
159
+ redistributable: permissive && redistAllowed,
160
+ // May this license be USED in a NON-COMMERCIAL, non-redistributing context
161
+ // (e.g. an academic eval run, a non-commercial prize)? This is the lane an
162
+ // -NC license explicitly permits: NonCommercial / ShareAlike / NoDerivatives
163
+ // / use-but-no-redistribute (SIL) all allow non-commercial *use* even though
164
+ // they are NOT commercial-safe. Mixed / unstated / unknown stay fail-safe
165
+ // restricted (we cannot confirm the terms). See isUsageAllowed().
166
+ nonCommercialUseAllowed: NC_USABLE_TIERS.has(tier),
167
+ };
168
+ }
169
+
170
+ // Tiers whose license PERMITS non-commercial, non-redistributing use. (DERIVED
171
+ // is handled separately in isUsageAllowed — its eligibility is the AND over its
172
+ // upstreams.) Mixed / unstated / unknown are deliberately excluded: fail-safe.
173
+ const NC_USABLE_TIERS = new Set([
174
+ TIERS.PERMISSIVE, TIERS.NONCOMMERCIAL, TIERS.SHAREALIKE,
175
+ TIERS.NODERIVATIVES, TIERS.RESTRICTED_NOREDIST,
176
+ ]);
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Register loading (shared/licenses.json + corrections overlay), cached.
180
+ // ---------------------------------------------------------------------------
181
+ let _register = null;
182
+
183
+ function readSources(path) {
184
+ if (!existsSync(path)) return {};
185
+ try {
186
+ const j = JSON.parse(readFileSync(path, 'utf8'));
187
+ return j.sources || {};
188
+ } catch {
189
+ return {};
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Load (and cache) the merged license register.
195
+ *
196
+ * THREE LAYERS, weakest to strongest:
197
+ *
198
+ * 1. shared/licenses.json — the generated base. 212 of its 333 entries
199
+ * were written by populate-licenses.mjs's
200
+ * pattern-matching, which fabricated versions
201
+ * and dropped ND clauses. Frozen under the
202
+ * standing wholesale-regen hold.
203
+ * 2. shared/license-corrections.json — human determinations, each with a
204
+ * recorded `_basis`. These fill gaps the base
205
+ * gets wrong or leaves unstated.
206
+ * 3. shared/license-evidence.json — what the UPSTREAM ACTUALLY SAYS:
207
+ * Zenodo deposit records, shipped LICENSE
208
+ * files, CLDF metadata. Built by
209
+ * cli/scripts/build-license-evidence.mjs.
210
+ *
211
+ * Evidence outranks a correction ONLY WHEN IT IS MORE RESTRICTIVE.
212
+ *
213
+ * That asymmetry is the whole point. A correction claiming MORE freedom than
214
+ * the upstream granted is wrong and must be overridden — that is how
215
+ * grollemundbantu came to be commercial-safe when its depositor said
216
+ * CC-BY-NC, and how nts lost its NoDerivatives clause. But a correction
217
+ * claiming LESS freedom than the grant allows is not an error at all: it is a
218
+ * policy choice about our own conduct, and we are always free to bind
219
+ * ourselves more tightly than a licence requires.
220
+ *
221
+ * ELCat is the case that forced this rule. Its correction's own `_basis`
222
+ * reads: "cldf-datasets/elcat metadata declares CC-BY-4.0, but kept
223
+ * NonCommercial as a conservative, sovereignty-aware call (endangered-language
224
+ * data)." The upstream licence was already known; NC was chosen deliberately
225
+ * for endangered-language data. A naive "evidence always wins" layer silently
226
+ * repealed a sovereignty decision — which is precisely the kind of quiet
227
+ * loosening this whole audit exists to prevent.
228
+ *
229
+ * Where evidence is silent (96 sources have no identifiable upstream
230
+ * statement), corrections govern: a documented negative finding beats nothing.
231
+ *
232
+ * Evidence only overwrites the licence FACTS (spdx + obligation flags). It
233
+ * never touches attribution, urls or notes, which the register carries and the
234
+ * evidence layer does not.
235
+ */
236
+
237
+ /**
238
+ * How much a record restricts us. Higher = tighter. Used only to decide which
239
+ * layer wins; never exposed as a licence fact.
240
+ */
241
+ function _restrictionScore(rec) {
242
+ if (!rec) return -1;
243
+ const s = String(rec.license_spdx || '').toUpperCase();
244
+ const unresolved = !s || s === 'UNVERIFIED' || s === 'UNCONFIRMED' || /UNSTATED/.test(s);
245
+ return (unresolved ? 16 : 0)
246
+ + (rec.no_derivatives ? 8 : 0)
247
+ + (rec.non_commercial_only ? 4 : 0)
248
+ + (rec.requires_sharealike ? 2 : 0)
249
+ + (rec.allows_redistribution === 0 ? 1 : 0);
250
+ }
251
+ export function loadLicenseRegister({ reload = false } = {}) {
252
+ if (_register && !reload) return _register;
253
+ const base = readSources(LICENSES_PATH);
254
+ const corrections = readSources(CORRECTIONS_PATH);
255
+ const evidence = readSources(EVIDENCE_PATH);
256
+ const merged = new Map();
257
+ for (const [source, rec] of Object.entries(base)) merged.set(source, rec);
258
+ for (const [source, rec] of Object.entries(corrections)) {
259
+ merged.set(source, { ...(merged.get(source) || {}), ...rec, source });
260
+ }
261
+ for (const [source, rec] of Object.entries(evidence)) {
262
+ const spdx = rec?.resolved?.spdx;
263
+ if (!spdx) continue; // UNVERIFIED — leave the lower layers in place
264
+ const f = rec.resolved.flags || {};
265
+ const current = merged.get(source) || {};
266
+ const fromEvidence = {
267
+ ...current,
268
+ source,
269
+ license_spdx: spdx,
270
+ // `redistribution: null` means unknown and must not read as permission.
271
+ allows_redistribution: f.redistribution === true ? 1 : 0,
272
+ requires_attribution: f.attribution ? 1 : 0,
273
+ requires_sharealike: f.sharealike ? 1 : 0,
274
+ non_commercial_only: f.nonCommercial ? 1 : 0,
275
+ no_derivatives: f.noDerivatives ? 1 : 0,
276
+ _evidenceBasis: rec.resolved.basis,
277
+ _evidenceConflict: rec.conflict ? true : undefined,
278
+ };
279
+
280
+ // Only let evidence LOOSEN nothing. If the standing record already binds us
281
+ // more tightly than the upstream grant does, that is a deliberate choice
282
+ // (see the ELCat note above) and it stands — but we still record what the
283
+ // upstream actually says, so the gap between "what we may do" and "what we
284
+ // choose to do" is visible rather than lost.
285
+ if (_restrictionScore(current) > _restrictionScore(fromEvidence)) {
286
+ merged.set(source, {
287
+ ...current,
288
+ _upstreamSpdx: spdx,
289
+ _upstreamBasis: rec.resolved.basis,
290
+ _moreRestrictiveThanUpstream: true,
291
+ });
292
+ continue;
293
+ }
294
+ merged.set(source, fromEvidence);
295
+ }
296
+ _register = merged;
297
+ return merged;
298
+ }
299
+
300
+ /** Raw license record for a source (corrections win), or null if unknown. */
301
+ export function getLicense(source, opts) {
302
+ return loadLicenseRegister(opts).get(source) || null;
303
+ }
304
+
305
+ /** Classification for a source label. */
306
+ export function classifySource(source, opts) {
307
+ return classifyLicense(getLicense(source, opts));
308
+ }
309
+
310
+ /** Parse `[derived from X]` upstream labels out of a fact's notes. */
311
+ export function parseDerivedFrom(notes) {
312
+ if (!notes) return [];
313
+ const out = [];
314
+ const re = /\[derived from ([^\]]+)\]/gi;
315
+ let m;
316
+ while ((m = re.exec(String(notes))) !== null) {
317
+ for (const part of m[1].split(/[,+]/)) {
318
+ const t = part.trim();
319
+ if (t) out.push(t);
320
+ }
321
+ }
322
+ return out;
323
+ }
324
+
325
+ /**
326
+ * May this fact's source be used in the COMMERCIAL lane (the paid API)?
327
+ * For champollion-derived facts, pass `derivedFrom` (parsed from notes) — the
328
+ * result is the AND over every upstream (a derived value is only as free as its
329
+ * least-free input).
330
+ */
331
+ export function isCommercialSafe(source, { derivedFrom = null, opts } = {}) {
332
+ const cls = classifySource(source, opts);
333
+ if (cls.tier === TIERS.DERIVED) {
334
+ const ups = derivedFrom && derivedFrom.length ? derivedFrom : null;
335
+ if (!ups) return false; // unknown provenance → conservative
336
+ return ups.every((u) => classifySource(u, opts).commercialSafe);
337
+ }
338
+ return cls.commercialSafe;
339
+ }
340
+
341
+ /** May this fact's source be bulk-REDISTRIBUTED (e.g. a public data bundle)? */
342
+ export function isRedistributable(source, { derivedFrom = null, opts } = {}) {
343
+ const cls = classifySource(source, opts);
344
+ if (cls.tier === TIERS.DERIVED) {
345
+ const ups = derivedFrom && derivedFrom.length ? derivedFrom : null;
346
+ if (!ups) return false;
347
+ return ups.every((u) => classifySource(u, opts).redistributable);
348
+ }
349
+ return cls.redistributable;
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Use-based eligibility — "may THIS USE touch this source's data?"
354
+ // ---------------------------------------------------------------------------
355
+ //
356
+ // Project doctrine: "prize", "API", and "commercial" are
357
+ // ORTHOGONAL attributes, not one restricted bucket. A NonCommercial license
358
+ // (CC-BY-NC / CC-BY-NC-SA) gates COMMERCIAL use + REDISTRIBUTION specifically —
359
+ // it does NOT gate non-commercial use. So a non-commercial prize (an
360
+ // open-source-solution prize, or a corporation funding a prize for private /
361
+ // internal use) MAY use an NC dataset; a commercial / for-profit / API-served
362
+ // use may NOT.
363
+ //
364
+ // Eligibility is therefore computed from a USE descriptor — `use_context`
365
+ // ('commercial' | 'non-commercial') and `redistribution` (boolean) — combined
366
+ // with the dataset's license tier, instead of a coarse lane name:
367
+ //
368
+ // NC dataset → ALLOWED when use_context='non-commercial' AND no redistribution
369
+ // → BLOCKED when use_context='commercial' OR redistribution required
370
+ //
371
+ // Fail-safe throughout: an unknown / unstated / mixed license is treated as
372
+ // restricted in EVERY context. EdTeKLA / sovereignty corpora are quarantined a
373
+ // layer above this (registry flag + DB trigger) and never rank in ANY lane —
374
+ // this use-based nuance applies only to general NC sets.
375
+
376
+ /** The two recognized use contexts. */
377
+ export const USE_CONTEXTS = {
378
+ COMMERCIAL: 'commercial',
379
+ NON_COMMERCIAL: 'non-commercial',
380
+ };
381
+
382
+ /** Does this SPDX string carry a NonCommercial restriction? Pure (no I/O). */
383
+ export function isNonCommercialSpdx(spdx) {
384
+ if (!spdx) return false;
385
+ const s = String(spdx).toUpperCase();
386
+ return s.includes('-NC') || s.includes('NONCOMMERCIAL') || s.includes('NON-COMMERCIAL');
387
+ }
388
+
389
+ /**
390
+ * Is a given USE of a source's data allowed?
391
+ *
392
+ * @param {string} source facts.source label (or champollion-derived)
393
+ * @param {object} [o]
394
+ * @param {('commercial'|'non-commercial')} [o.use_context='non-commercial']
395
+ * The intended use. Anything other than 'non-commercial' is treated as
396
+ * 'commercial' (the most restrictive reading — fail-safe).
397
+ * @param {boolean} [o.redistribution=false] Does the use REDISTRIBUTE the data
398
+ * (re-publish / bundle the corpus content)? Only permissive licenses ever
399
+ * clear redistribution; an NC license never does.
400
+ * @param {string[]|null} [o.derivedFrom] Upstreams for a champollion-derived
401
+ * source (parsed from notes). A derived value is only as free as its
402
+ * least-free input under the SAME terms.
403
+ * @returns {{allowed:boolean, reason:string, tier:string, license_spdx:string|null,
404
+ * use_context:string, redistribution:boolean}}
405
+ */
406
+ export function isUsageAllowed(source, {
407
+ use_context = USE_CONTEXTS.NON_COMMERCIAL,
408
+ redistribution = false,
409
+ derivedFrom = null,
410
+ opts,
411
+ } = {}) {
412
+ // Normalize: only the exact 'non-commercial' token earns the relaxed lane;
413
+ // any other value (including a typo or undefined) falls back to 'commercial'.
414
+ const ctx = use_context === USE_CONTEXTS.NON_COMMERCIAL
415
+ ? USE_CONTEXTS.NON_COMMERCIAL
416
+ : USE_CONTEXTS.COMMERCIAL;
417
+ const cls = classifySource(source, opts);
418
+
419
+ // champollion-derived: AND over every upstream, under the SAME use terms.
420
+ if (cls.tier === TIERS.DERIVED) {
421
+ const ups = derivedFrom && derivedFrom.length ? derivedFrom : null;
422
+ if (!ups) {
423
+ return {
424
+ allowed: false,
425
+ reason: 'champollion-derived value with unknown provenance — fail-safe restricted',
426
+ tier: cls.tier, license_spdx: cls.license_spdx, use_context: ctx, redistribution,
427
+ };
428
+ }
429
+ const allowed = ups.every(
430
+ (u) => isUsageAllowed(u, { use_context: ctx, redistribution, opts }).allowed,
431
+ );
432
+ return {
433
+ allowed,
434
+ reason: allowed
435
+ ? 'derived value cleared (every upstream allows this use)'
436
+ : 'derived value blocked (an upstream restricts this use)',
437
+ tier: cls.tier, license_spdx: cls.license_spdx, use_context: ctx, redistribution,
438
+ };
439
+ }
440
+
441
+ let allowed;
442
+ let reason;
443
+ if (redistribution) {
444
+ allowed = cls.redistributable;
445
+ reason = allowed
446
+ ? 'redistribution cleared (permissive, redistribution-allowed)'
447
+ : `redistribution requires a permissive, redistribution-cleared license; '${cls.tier}' does not qualify`;
448
+ } else if (ctx === USE_CONTEXTS.COMMERCIAL) {
449
+ allowed = cls.commercialSafe;
450
+ reason = allowed
451
+ ? 'commercial use cleared (permissive)'
452
+ : `commercial use blocked: '${cls.tier}' license is not commercial-safe`;
453
+ } else {
454
+ // non-commercial, no redistribution — the lane an -NC license permits
455
+ allowed = cls.nonCommercialUseAllowed;
456
+ reason = allowed
457
+ ? 'non-commercial use permitted (no redistribution)'
458
+ : `non-commercial eligibility unconfirmed for '${cls.tier}' license — fail-safe restricted`;
459
+ }
460
+ return {
461
+ allowed, reason, tier: cls.tier, license_spdx: cls.license_spdx,
462
+ use_context: ctx, redistribution,
463
+ };
464
+ }
465
+
466
+ /**
467
+ * UI helper: a short license tag for attribution display.
468
+ * e.g. { label: 'CC-BY-NC · D-PLACE Ethnographic Atlas', tier, url }.
469
+ */
470
+ export function licenseTag(source, opts) {
471
+ const rec = getLicense(source, opts);
472
+ const cls = classifyLicense(rec);
473
+ const name = (rec && rec.attribution) ? rec.attribution.split(/[.,(]/)[0].trim() : source;
474
+ const spdx = cls.license_spdx && cls.license_spdx !== 'LicenseRef-Unstated'
475
+ ? cls.license_spdx.replace(/^LicenseRef-/, '')
476
+ : 'license unstated';
477
+ return { label: `${spdx} · ${name}`, tier: cls.tier, license_spdx: cls.license_spdx, url: cls.license_url };
478
+ }