From 8300c189b89827fdcc9446d643d76de427c926bc Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Mon, 6 Jul 2026 16:51:17 +0800 Subject: [PATCH 1/6] feat(core): programmable evaluatePermissions; evaluateUse intents become presets --- .changeset/evaluate-permissions.md | 5 ++ .../core/src/__tests__/evaluate-use.test.ts | 31 +++++++- packages/core/src/evaluate-use.ts | 73 +++++++++++++------ packages/core/src/index.ts | 4 +- 4 files changed, 89 insertions(+), 24 deletions(-) create mode 100644 .changeset/evaluate-permissions.md diff --git a/.changeset/evaluate-permissions.md b/.changeset/evaluate-permissions.md new file mode 100644 index 0000000..8acb6b4 --- /dev/null +++ b/.changeset/evaluate-permissions.md @@ -0,0 +1,5 @@ +--- +"@refkit/core": minor +--- + +Export evaluatePermissions/PermissionKey/EvaluateOptions — programmable strict-deny gate; evaluateUse intents are now presets over it (behavior unchanged). diff --git a/packages/core/src/__tests__/evaluate-use.test.ts b/packages/core/src/__tests__/evaluate-use.test.ts index 4da6995..62a828f 100644 --- a/packages/core/src/__tests__/evaluate-use.test.ts +++ b/packages/core/src/__tests__/evaluate-use.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { evaluateUse, NOT_LEGAL_ADVICE } from '../evaluate-use' +import { evaluatePermissions, evaluateUse, NOT_LEGAL_ADVICE } from '../evaluate-use' import type { RightsRecord } from '../rights' import { LICENSE_FACTS } from '../license' import type { LicenseId } from '../license' @@ -102,3 +102,32 @@ describe('evaluateUse — strict-deny', () => { expect(evaluateUse(rec('CC-BY-ND'), 'redistribution').decision).toBe('allowed-with-attribution') }) }) + +describe('evaluatePermissions — programmable strict-deny gate', () => { + it('custom permission set: derivatives-only on CC-BY-ND → denied, naming CC-BY-ND', () => { + const v = evaluatePermissions(rec('CC-BY-ND'), ['derivatives']) + expect(v.decision).toBe('denied') + expect(v.reasons.join(' ')).toContain('CC-BY-ND') + }) + + it('empty required set on CC-BY with enforceAttribution (default true) → allowed-with-attribution', () => { + const v = evaluatePermissions(rec('CC-BY'), []) + expect(v.decision).toBe('allowed-with-attribution') + }) + + it('unknown license → needs-review regardless of required permissions', () => { + const v = evaluatePermissions(rec('unknown'), ['commercialUse', 'derivatives', 'redistribution']) + expect(v.decision).toBe('needs-review') + expect(v.confidence).toBe('low') + }) + + it('preset parity: evaluatePermissions(rec, [commercialUse], undefined, {denyEditorialOnly:true}) equals evaluateUse(rec, commercial-product)', () => { + const licenses: LicenseId[] = ['CC0-1.0', 'CC-BY', 'CC-BY-NC', 'CC-BY-ND', 'proprietary', 'unknown'] + for (const license of licenses) { + const viaPermissions = evaluatePermissions(rec(license), ['commercialUse'], undefined, { denyEditorialOnly: true }) + const viaUse = evaluateUse(rec(license), 'commercial-product') + expect(viaPermissions.decision).toBe(viaUse.decision) + expect(viaPermissions.reasons).toEqual(viaUse.reasons) + } + }) +}) diff --git a/packages/core/src/evaluate-use.ts b/packages/core/src/evaluate-use.ts index 5a99180..ad62c44 100644 --- a/packages/core/src/evaluate-use.ts +++ b/packages/core/src/evaluate-use.ts @@ -14,37 +14,43 @@ export interface Verdict { export const NOT_LEGAL_ADVICE = 'Heuristic based on source-declared license/ToS facts; not legal advice.' -// Which permission(s) an intent requires to be granted (true). -// strict-deny: a required permission that is false → denied; 'unknown' → needs-review. -function requiredPermissions(intent: Intent): Array> { - switch (intent) { - case 'commercial-product': return ['commercialUse'] - case 'ai-generation-input': return ['commercialUse', 'derivatives'] - case 'redistribution': return ['redistribution'] - case 'internal-moodboard': return [] // lenient; gated only by the unknown/proprietary checks below - } +/** The tri-state permission axes of LicenseFacts. */ +export type PermissionKey = 'commercialUse' | 'derivatives' | 'redistribution' + +export interface EvaluateOptions { + /** Treat editorial-only sources as denied (commercial-flavored uses). Default false. */ + denyEditorialOnly?: boolean + /** Enforce attributionRequired as allowed-with-attribution. Default true; presets + * disable it for internal-moodboard (note-only). */ + enforceAttribution?: boolean } -export function evaluateUse( +/** Low-level, programmable strict-deny gate: unknown license → needs-review; + * jurisdiction mismatch → needs-review; each required permission false → denied, + * 'unknown' → needs-review; else allowed(-with-attribution). evaluateUse's four + * intents are presets over this. */ +export function evaluatePermissions( r: RightsRecord, - intent: Intent, + required: readonly PermissionKey[], ctx?: { userJurisdiction?: string }, + opts?: EvaluateOptions, ): Verdict { + const denyEditorialOnly = opts?.denyEditorialOnly ?? false + const enforceAttribution = opts?.enforceAttribution ?? true + const facts = factsFor(r.license) const reasons: string[] = [] const confidence: 'high' | 'low' = r.license === 'unknown' ? 'low' : 'high' const base = { reasons, confidence, disclaimer: NOT_LEGAL_ADVICE } - const commercialIntent = intent === 'commercial-product' || intent === 'ai-generation-input' - - // Unknown license: never allowed — needs-review regardless of intent. + // Unknown license: never allowed — needs-review regardless of required permissions. if (r.license === 'unknown') { reasons.push('license could not be determined (strict-deny)') return { decision: 'needs-review', ...base } } - // editorial-only blocks commercial/AI use outright. - if (r.editorialOnly && commercialIntent) { + // editorial-only blocks commercial-flavored uses outright, when requested. + if (r.editorialOnly && denyEditorialOnly) { reasons.push('source marked editorial-only') return { decision: 'denied', ...base } } @@ -57,7 +63,7 @@ export function evaluateUse( } // Evaluate the required permissions with strict-deny semantics. - for (const perm of requiredPermissions(intent)) { + for (const perm of required) { const value = facts[perm] as Tri if (value === false) { reasons.push(`${perm} not granted by ${r.license}`) @@ -69,14 +75,39 @@ export function evaluateUse( } } - reasons.push(`permitted for ${intent} under ${r.license}`) - // internal-moodboard is lenient: attribution not enforced for internal-only use. - if (facts.attributionRequired && intent === 'internal-moodboard') { + reasons.push(`permitted for ${required.join('+') || 'use'} under ${r.license}`) + // Lenient callers (e.g. internal-moodboard) surface a note instead of enforcing. + if (facts.attributionRequired && !enforceAttribution) { reasons.push('attribution required by license but not enforced for internal-moodboard use') } const decision: Decision = - facts.attributionRequired && intent !== 'internal-moodboard' + facts.attributionRequired && enforceAttribution ? 'allowed-with-attribution' : 'allowed' return { decision, ...base } } + +// Which permission(s) an intent requires to be granted (true), plus the +// evaluatePermissions options that reproduce evaluateUse's historical behavior. +function presetFor(intent: Intent): { required: PermissionKey[]; opts: EvaluateOptions } { + switch (intent) { + case 'commercial-product': + return { required: ['commercialUse'], opts: { denyEditorialOnly: true, enforceAttribution: true } } + case 'ai-generation-input': + return { required: ['commercialUse', 'derivatives'], opts: { denyEditorialOnly: true, enforceAttribution: true } } + case 'redistribution': + return { required: ['redistribution'], opts: { denyEditorialOnly: false, enforceAttribution: true } } + case 'internal-moodboard': + // lenient; gated only by the unknown/jurisdiction checks in evaluatePermissions + return { required: [], opts: { denyEditorialOnly: false, enforceAttribution: false } } + } +} + +export function evaluateUse( + r: RightsRecord, + intent: Intent, + ctx?: { userJurisdiction?: string }, +): Verdict { + const { required, opts } = presetFor(intent) + return evaluatePermissions(r, required, ctx, opts) +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7bf122f..c4e9de3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,8 +20,8 @@ export { hammingDistance, dedupeReferences } from './dedup' export type { DedupeOptions } from './dedup' export { mergeReferences } from './merge' export type { MergeOptions } from './merge' -export { evaluateUse, NOT_LEGAL_ADVICE } from './evaluate-use' -export type { Intent, Decision, Verdict } from './evaluate-use' +export { evaluateUse, evaluatePermissions, NOT_LEGAL_ADVICE } from './evaluate-use' +export type { Intent, Decision, Verdict, PermissionKey, EvaluateOptions } from './evaluate-use' export { defineProvider } from './provider' export type { ReferenceProvider, From fdfafcf75decb1081c5e963c87824ed3dea40414 Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Mon, 6 Jul 2026 16:56:33 +0800 Subject: [PATCH 2/6] fix(core): preserve evaluateUse's historical success-reason wording via preset label --- packages/core/src/__tests__/evaluate-use.test.ts | 13 +++++++++++-- packages/core/src/evaluate-use.ts | 15 ++++++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/core/src/__tests__/evaluate-use.test.ts b/packages/core/src/__tests__/evaluate-use.test.ts index 62a828f..d85291a 100644 --- a/packages/core/src/__tests__/evaluate-use.test.ts +++ b/packages/core/src/__tests__/evaluate-use.test.ts @@ -121,13 +121,22 @@ describe('evaluatePermissions — programmable strict-deny gate', () => { expect(v.confidence).toBe('low') }) - it('preset parity: evaluatePermissions(rec, [commercialUse], undefined, {denyEditorialOnly:true}) equals evaluateUse(rec, commercial-product)', () => { + it('preset parity: evaluatePermissions(rec, [commercialUse], undefined, {denyEditorialOnly:true, label}) equals evaluateUse(rec, commercial-product)', () => { const licenses: LicenseId[] = ['CC0-1.0', 'CC-BY', 'CC-BY-NC', 'CC-BY-ND', 'proprietary', 'unknown'] for (const license of licenses) { - const viaPermissions = evaluatePermissions(rec(license), ['commercialUse'], undefined, { denyEditorialOnly: true }) + const viaPermissions = evaluatePermissions(rec(license), ['commercialUse'], undefined, { denyEditorialOnly: true, label: 'commercial-product' }) const viaUse = evaluateUse(rec(license), 'commercial-product') expect(viaPermissions.decision).toBe(viaUse.decision) expect(viaPermissions.reasons).toEqual(viaUse.reasons) } }) + + it('evaluateUse success reasons name the intent (historical wording)', () => { + expect(evaluateUse(rec('CC0-1.0'), 'commercial-product').reasons).toContain('permitted for commercial-product under CC0-1.0') + expect(evaluateUse(rec('CC0-1.0'), 'internal-moodboard').reasons).toContain('permitted for internal-moodboard under CC0-1.0') + }) + + it('standalone evaluatePermissions defaults the label to the permission set', () => { + expect(evaluatePermissions(rec('CC0-1.0'), ['commercialUse']).reasons).toContain('permitted for commercialUse under CC0-1.0') + }) }) diff --git a/packages/core/src/evaluate-use.ts b/packages/core/src/evaluate-use.ts index ad62c44..88dca7c 100644 --- a/packages/core/src/evaluate-use.ts +++ b/packages/core/src/evaluate-use.ts @@ -23,6 +23,10 @@ export interface EvaluateOptions { /** Enforce attributionRequired as allowed-with-attribution. Default true; presets * disable it for internal-moodboard (note-only). */ enforceAttribution?: boolean + /** Label used in the success-path reason ("permitted for