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.
- package/LICENSE +133 -0
- package/README.md +387 -0
- package/bin/cli.js +278 -0
- package/index.js +135 -0
- package/lib/api-key.js +127 -0
- package/lib/autofix.js +432 -0
- package/lib/bridge/method_bridge.py +430 -0
- package/lib/card-source-resolution.mjs +284 -0
- package/lib/cards/cache.js +169 -0
- package/lib/cards/env.js +82 -0
- package/lib/cards/fetch-card-child.js +38 -0
- package/lib/cards/reader.js +435 -0
- package/lib/cards/refresh.js +111 -0
- package/lib/cards/remote.js +387 -0
- package/lib/cldf-export.mjs +540 -0
- package/lib/cldf-terms.mjs +62 -0
- package/lib/command-help.js +790 -0
- package/lib/commands/audit.js +49 -0
- package/lib/commands/card.js +454 -0
- package/lib/commands/doctor.js +559 -0
- package/lib/commands/fonts.js +489 -0
- package/lib/commands/help.js +91 -0
- package/lib/commands/init.js +1259 -0
- package/lib/commands/integrity.js +148 -0
- package/lib/commands/leaderboard.js +478 -0
- package/lib/commands/lint.js +30 -0
- package/lib/commands/models.js +177 -0
- package/lib/commands/plugin.js +103 -0
- package/lib/commands/provenance.js +45 -0
- package/lib/commands/recommend.js +75 -0
- package/lib/commands/register-corpus.js +678 -0
- package/lib/commands/repair-script.js +42 -0
- package/lib/commands/seal-corpus.js +355 -0
- package/lib/commands/seo.js +72 -0
- package/lib/commands/serve.js +147 -0
- package/lib/commands/status.js +265 -0
- package/lib/commands/submit.js +332 -0
- package/lib/commands/sync.js +89 -0
- package/lib/commands/tm.js +573 -0
- package/lib/commands/verify.js +39 -0
- package/lib/commands/watch.js +20 -0
- package/lib/commands/wrap.js +138 -0
- package/lib/commands/xliff.js +327 -0
- package/lib/commercial-eligibility.js +235 -0
- package/lib/concurrent.js +87 -0
- package/lib/config.js +523 -0
- package/lib/contamination-lane.js +76 -0
- package/lib/content-sync.js +731 -0
- package/lib/content.js +733 -0
- package/lib/corpus-registration.mjs +608 -0
- package/lib/cost-report.js +346 -0
- package/lib/diff.js +155 -0
- package/lib/docusaurus-sync.js +1256 -0
- package/lib/flatten.js +55 -0
- package/lib/format.js +954 -0
- package/lib/hash.js +159 -0
- package/lib/icu.js +473 -0
- package/lib/integrity.js +689 -0
- package/lib/license-gate.mjs +478 -0
- package/lib/license-identify.mjs +229 -0
- package/lib/lint.js +629 -0
- package/lib/method-manifest.js +60 -0
- package/lib/methods/anthropic.js +140 -0
- package/lib/methods/apertium.js +163 -0
- package/lib/methods/api.js +316 -0
- package/lib/methods/base.js +184 -0
- package/lib/methods/content-separator.js +45 -0
- package/lib/methods/deepl.js +426 -0
- package/lib/methods/direct-llm.js +586 -0
- package/lib/methods/external.js +332 -0
- package/lib/methods/fetch-with-retry.js +124 -0
- package/lib/methods/gemini.js +147 -0
- package/lib/methods/google-translate.js +402 -0
- package/lib/methods/http-utils.js +122 -0
- package/lib/methods/libretranslate.js +314 -0
- package/lib/methods/llm-coached.js +670 -0
- package/lib/methods/llm.js +592 -0
- package/lib/methods/local.js +76 -0
- package/lib/methods/microsoft-translator.js +331 -0
- package/lib/methods/openai.js +131 -0
- package/lib/methods/openrouter-client.js +327 -0
- package/lib/methods/openrouter-pricing.js +156 -0
- package/lib/methods/provider-env.js +115 -0
- package/lib/methods/provider-pricing.js +310 -0
- package/lib/methods/tilde.js +150 -0
- package/lib/methods/translated.js +229 -0
- package/lib/methods/translation-error.js +80 -0
- package/lib/models.js +258 -0
- package/lib/no-translate.js +233 -0
- package/lib/output.js +238 -0
- package/lib/pairs.js +547 -0
- package/lib/plugins.js +447 -0
- package/lib/provenance.js +323 -0
- package/lib/recommend.js +648 -0
- package/lib/registers.js +1185 -0
- package/lib/repair-script.js +266 -0
- package/lib/scripts.js +994 -0
- package/lib/seal.mjs +464 -0
- package/lib/sealed-qualifier.mjs +211 -0
- package/lib/security.js +59 -0
- package/lib/segment.js +369 -0
- package/lib/seo.js +275 -0
- package/lib/serve.js +854 -0
- package/lib/string-classify.js +85 -0
- package/lib/submit.mjs +344 -0
- package/lib/sync.js +969 -0
- package/lib/tags/bcp47.js +202 -0
- package/lib/tags/resolve.js +314 -0
- package/lib/terminology.js +111 -0
- package/lib/tm-seed.js +294 -0
- package/lib/tm.js +515 -0
- package/lib/translate-pair.js +197 -0
- package/lib/translate.js +203 -0
- package/lib/types.js +230 -0
- package/lib/validate.js +510 -0
- package/lib/verify.js +451 -0
- package/lib/watch.js +145 -0
- package/lib/xliff.js +184 -0
- package/package.json +93 -0
- package/shared/ATTRIBUTION.md +145 -0
- package/shared/CORPORA-CARDS.md +288 -0
- package/shared/DATA-SOVEREIGNTY.md +500 -0
- package/shared/LANGUAGE-CARD-FIELDS.md +532 -0
- package/shared/card-lint-baseline.json +3189 -0
- package/shared/cards-fallback.json +1 -0
- package/shared/catalogue/card-config.json +6091 -0
- package/shared/catalogue/external-results.json +3888 -0
- package/shared/catalogue/gender-guidance.json +1038 -0
- package/shared/catalogue/method-coverage.json +1751 -0
- package/shared/catalogue/metric-coverage.json +170 -0
- package/shared/catalogue/metric-reliability.json +1 -0
- package/shared/catalogue/register-presets.json +3180 -0
- package/shared/catalogue/vitality-scales.json +55 -0
- package/shared/cldr-index.json +1115 -0
- package/shared/code-bridge.json +253 -0
- package/shared/corpora-cards-v1-reference.md +281 -0
- package/shared/curated-dictionary-flags.json +35 -0
- package/shared/curated-endonyms.json +35 -0
- package/shared/curated-fsts.json +51 -0
- package/shared/curated-orthography-conventions.json +26 -0
- package/shared/curated-sil-resources.json +374 -0
- package/shared/curated-tools.json +41 -0
- package/shared/docent/corpus.json +11333 -0
- package/shared/docent/faq.en.json +564 -0
- package/shared/docent/register-blocks.json +60 -0
- package/shared/docent/system-prompt.md +144 -0
- package/shared/domain-taxonomy.json +35 -0
- package/shared/explainers/glossary.json +2975 -0
- package/shared/explainers/tc-features.json +20112 -0
- package/shared/explainers/term-watchlist.json +147 -0
- package/shared/human-services.json +59 -0
- package/shared/license-corrections.json +261 -0
- package/shared/license-evidence.json +13452 -0
- package/shared/licenses.json +6781 -0
- package/shared/method-registry.json +236 -0
- package/shared/metric-registry.json +620 -0
- package/shared/model-aliases.json +7 -0
- package/shared/schemas/champollion-plugin.schema.json +206 -0
- package/shared/schemas/corpora-card.schema.json +957 -0
- package/shared/schemas/domain-taxonomy.schema.json +64 -0
- package/shared/schemas/external-results.schema.json +314 -0
- package/shared/schemas/human-services.schema.json +90 -0
- package/shared/schemas/language-card.schema.json +1308 -0
- package/shared/schemas/licenses.schema.json +155 -0
- package/shared/schemas/method-card.schema.json +412 -0
- package/shared/schemas/method-registry.schema.json +85 -0
- package/shared/schemas/metric-registry.schema.json +96 -0
- package/shared/schemas/metric-reliability.schema.json +178 -0
- package/shared/schemas/model-aliases.schema.json +27 -0
- 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
|
+
}
|