@startsimpli/ui 0.4.108 → 0.4.110
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/package.json +1 -1
- package/src/components/collection/__tests__/review.test.ts +61 -0
- package/src/components/collection/index.ts +2 -0
- package/src/components/collection/review.ts +35 -3
- package/src/components/validation/__tests__/check-policy.test.ts +222 -0
- package/src/components/validation/__tests__/checks.test.ts +54 -1
- package/src/components/validation/checks.ts +91 -14
- package/src/components/validation/index.ts +15 -0
- package/src/components/validation/policy.ts +197 -0
package/package.json
CHANGED
|
@@ -223,3 +223,64 @@ describe('reviewHostOf / fieldStr', () => {
|
|
|
223
223
|
expect(fieldStr(undefined, 'x')).toBe('');
|
|
224
224
|
});
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
// ---- decision vocabulary (bd startsim-b313v) ----
|
|
228
|
+
//
|
|
229
|
+
// THE DEFECT THESE COVER. The three labels were string literals inside
|
|
230
|
+
// `reviewDecisions`, so every tenant's every reviewable type said the same three
|
|
231
|
+
// words. On the OGMC tenant that collided in the customer's hands: approving a
|
|
232
|
+
// TOPIC ("this subject is worth writing") and approving a DRAFT ("this piece is
|
|
233
|
+
// publishable") are different KINDS of decision, and both rendered "Approve".
|
|
234
|
+
// Her question, mid-walkthrough: "so if I go, I click approve now, does it
|
|
235
|
+
// approve all of them?"
|
|
236
|
+
|
|
237
|
+
describe('decision labels are the consumer’s to name', () => {
|
|
238
|
+
const type = {
|
|
239
|
+
id: 't',
|
|
240
|
+
key: 'topic',
|
|
241
|
+
label: 'Topic',
|
|
242
|
+
attributes: [
|
|
243
|
+
{
|
|
244
|
+
id: 'a',
|
|
245
|
+
name: 'status',
|
|
246
|
+
dataType: 'enum' as const,
|
|
247
|
+
required: false,
|
|
248
|
+
config: { choices: ['suggested', 'ready', 'written', 'rejected'] },
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
it('keeps the generic words when a consumer supplies none', () => {
|
|
254
|
+
expect(reviewDecisions(resolveReviewConfig(type)).map((d) => d.label)).toEqual([
|
|
255
|
+
'Approve',
|
|
256
|
+
'Needs work',
|
|
257
|
+
'Reject',
|
|
258
|
+
]);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('lets a consumer name what the decision is ABOUT', () => {
|
|
262
|
+
const cfg = resolveReviewConfig(type, {
|
|
263
|
+
decisionLabels: { approve: 'Approve topic', reject: 'Reject topic' },
|
|
264
|
+
});
|
|
265
|
+
const byKey = Object.fromEntries(reviewDecisions(cfg).map((d) => [d.key, d.label]));
|
|
266
|
+
expect(byKey.approve).toBe('Approve topic');
|
|
267
|
+
expect(byKey.reject).toBe('Reject topic');
|
|
268
|
+
// Unnamed decisions keep the default rather than going blank.
|
|
269
|
+
expect(byKey.needs_work).toBe('Needs work');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('ignores a blank override instead of rendering an unlabelled button', () => {
|
|
273
|
+
const cfg = resolveReviewConfig(type, { decisionLabels: { approve: ' ' } });
|
|
274
|
+
expect(reviewDecisions(cfg).find((d) => d.key === 'approve')?.label).toBe('Approve');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('leaves every other part of the decision alone — this is copy, not behaviour', () => {
|
|
278
|
+
const plain = reviewDecisions(resolveReviewConfig(type));
|
|
279
|
+
const named = reviewDecisions(
|
|
280
|
+
resolveReviewConfig(type, { decisionLabels: { approve: 'Approve topic' } }),
|
|
281
|
+
);
|
|
282
|
+
expect(named.map((d) => [d.key, d.status, d.verdict, d.tone, d.advance, d.shortcut])).toEqual(
|
|
283
|
+
plain.map((d) => [d.key, d.status, d.verdict, d.tone, d.advance, d.shortcut]),
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
@@ -169,6 +169,23 @@ export interface ReviewConfig {
|
|
|
169
169
|
sourceAttrs?: string[];
|
|
170
170
|
/** Drop the middle "Needs work" decision — a binary accept/reject flow (default false). */
|
|
171
171
|
omitNeedsWork?: boolean;
|
|
172
|
+
/**
|
|
173
|
+
* What each decision is CALLED, by decision key ('approve' | 'needs_work' |
|
|
174
|
+
* 'reject'). Anything omitted keeps the generic default.
|
|
175
|
+
*
|
|
176
|
+
* WHY THIS EXISTS (bd startsim-b313v). The labels used to be string literals
|
|
177
|
+
* in `reviewDecisions` below, so every tenant's every reviewable type rendered
|
|
178
|
+
* the same three words. On OGMC that collided in the customer's hands: a
|
|
179
|
+
* reviewer approving a TOPIC ("this subject is worth writing") and a reviewer
|
|
180
|
+
* approving a DRAFT ("this piece is publishable") both pressed a button
|
|
181
|
+
* labelled "Approve", and she asked, mid-walkthrough, "so if I go, I click
|
|
182
|
+
* approve now, does it approve all of them?"
|
|
183
|
+
*
|
|
184
|
+
* A workspace cannot answer that from here — only the consumer knows what its
|
|
185
|
+
* records ARE — so the vocabulary is the consumer's to supply. Defaults are
|
|
186
|
+
* unchanged, so nothing that does not pass this renders differently.
|
|
187
|
+
*/
|
|
188
|
+
decisionLabels?: Record<string, string>;
|
|
172
189
|
}
|
|
173
190
|
|
|
174
191
|
export interface ResolvedReview {
|
|
@@ -185,6 +202,7 @@ export interface ResolvedReview {
|
|
|
185
202
|
metaAttrs: string[];
|
|
186
203
|
sourceAttrs: string[];
|
|
187
204
|
omitNeedsWork: boolean;
|
|
205
|
+
decisionLabels: Record<string, string>;
|
|
188
206
|
}
|
|
189
207
|
|
|
190
208
|
const DEFAULT_SOURCE_ATTRS = ['source_1', 'source_2', 'source_3', 'sources'];
|
|
@@ -216,9 +234,23 @@ export function resolveReviewConfig(
|
|
|
216
234
|
// Keep source attrs the caller asked for; default to the ones this type declares.
|
|
217
235
|
sourceAttrs: (config.sourceAttrs ?? DEFAULT_SOURCE_ATTRS).filter((a) => declared.has(a)),
|
|
218
236
|
omitNeedsWork: config.omitNeedsWork ?? false,
|
|
237
|
+
decisionLabels: config.decisionLabels ?? {},
|
|
219
238
|
};
|
|
220
239
|
}
|
|
221
240
|
|
|
241
|
+
/** The default name of a decision, when the consumer supplies none. */
|
|
242
|
+
export const DEFAULT_DECISION_LABELS: Record<string, string> = {
|
|
243
|
+
approve: 'Approve',
|
|
244
|
+
needs_work: 'Needs work',
|
|
245
|
+
reject: 'Reject',
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** What a decision is called on THIS surface — the consumer's word, else the default. */
|
|
249
|
+
export function decisionLabel(key: string, cfg: Pick<ResolvedReview, 'decisionLabels'>): string {
|
|
250
|
+
const supplied = cfg.decisionLabels?.[key];
|
|
251
|
+
return supplied && supplied.trim() ? supplied : DEFAULT_DECISION_LABELS[key] ?? key;
|
|
252
|
+
}
|
|
253
|
+
|
|
222
254
|
// ---- the single review decision (Approve / Needs work / Reject) ----
|
|
223
255
|
//
|
|
224
256
|
// status and team_verdict are two fields with different consumers (status = the
|
|
@@ -262,7 +294,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
|
|
|
262
294
|
if (r.transitions.approve) {
|
|
263
295
|
out.push({
|
|
264
296
|
key: 'approve',
|
|
265
|
-
label: '
|
|
297
|
+
label: decisionLabel('approve', r),
|
|
266
298
|
status: r.transitions.approve,
|
|
267
299
|
verdict: verdictOf('good'),
|
|
268
300
|
tone: 'good',
|
|
@@ -276,7 +308,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
|
|
|
276
308
|
if (!r.omitNeedsWork && entry && (editVerdict || entry !== r.transitions.approve)) {
|
|
277
309
|
out.push({
|
|
278
310
|
key: 'needs_work',
|
|
279
|
-
label: '
|
|
311
|
+
label: decisionLabel('needs_work', r),
|
|
280
312
|
status: entry,
|
|
281
313
|
verdict: editVerdict,
|
|
282
314
|
tone: 'neutral',
|
|
@@ -289,7 +321,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
|
|
|
289
321
|
if (r.transitions.reject) {
|
|
290
322
|
out.push({
|
|
291
323
|
key: 'reject',
|
|
292
|
-
label: '
|
|
324
|
+
label: decisionLabel('reject', r),
|
|
293
325
|
status: r.transitions.reject,
|
|
294
326
|
verdict: verdictOf('bad'),
|
|
295
327
|
tone: 'bad',
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bd startsim-4muob (c)/(d) — check policy resolves per content type and language.
|
|
3
|
+
*
|
|
4
|
+
* ONE BLOG BAND WAS APPLIED TO EVERY CONTENT TYPE. `ContentChecksConfig` has
|
|
5
|
+
* always taken per-call band overrides, but nothing ever resolved them from the
|
|
6
|
+
* `content_type` the call site had in hand, so a weekly brief and a long-form
|
|
7
|
+
* evergreen guide were judged against the same 400–500 words.
|
|
8
|
+
*
|
|
9
|
+
* THE NUMBERS IN THIS FILE ARE FIXTURES, and must stay fixtures. Which band a
|
|
10
|
+
* real content type gets is a TENANT's editorial decision (bd startsim-wncr6);
|
|
11
|
+
* the day one customer's house style is written down in @startsimpli/ui is the
|
|
12
|
+
* day every other tenant starts shipping it. What this package owns is the
|
|
13
|
+
* mechanism and the defaults — so these tests prove the mechanism with content
|
|
14
|
+
* types no tenant has.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
resolveCheckPolicy,
|
|
18
|
+
type CheckPolicySet,
|
|
19
|
+
} from '../policy';
|
|
20
|
+
import { runContentChecks } from '../checks';
|
|
21
|
+
|
|
22
|
+
const words = (n: number) => Array.from({ length: n }, (_, i) => `w${i}`).join(' ');
|
|
23
|
+
const blogBandOf = (set: CheckPolicySet, contentType?: string, language?: string) =>
|
|
24
|
+
resolveCheckPolicy(set, { contentType, language }).blogWords;
|
|
25
|
+
|
|
26
|
+
/** A fixture tenant's policy. Arbitrary numbers, deliberately not anyone's. */
|
|
27
|
+
const TENANT: CheckPolicySet = [
|
|
28
|
+
{ contentType: 'short_form', policy: { blogWords: [300, 400] } },
|
|
29
|
+
{ contentType: 'long_form', policy: { blogWords: [1200, 2000] } },
|
|
30
|
+
{ contentType: 'plain_form', policy: { blogWords: [600, 800] } },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
describe('(c) a content type resolves its own band', () => {
|
|
34
|
+
it('three content types yield three different blog bands', () => {
|
|
35
|
+
const short = blogBandOf(TENANT, 'short_form');
|
|
36
|
+
const long = blogBandOf(TENANT, 'long_form');
|
|
37
|
+
const plain = blogBandOf(TENANT, 'plain_form');
|
|
38
|
+
|
|
39
|
+
expect(short).toEqual([300, 400]);
|
|
40
|
+
expect(long).toEqual([1200, 2000]);
|
|
41
|
+
expect(plain).toEqual([600, 800]);
|
|
42
|
+
// Name the property the bug violated: they are not all the same band.
|
|
43
|
+
expect(new Set([short, long, plain].map((b) => String(b))).size).toBe(3);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('the resolved band is what the check actually judges against', () => {
|
|
47
|
+
// 500 words: in band for the long-form type's floor? No — under it. In band
|
|
48
|
+
// for plain_form? No — under. The point is that ONE draft gets three
|
|
49
|
+
// different verdicts, which is exactly what a single shared band cannot do.
|
|
50
|
+
const draft = { blog: words(500) };
|
|
51
|
+
const verdict = (contentType: string) =>
|
|
52
|
+
runContentChecks(draft, resolveCheckPolicy(TENANT, { contentType })).find(
|
|
53
|
+
(c) => c.id === 'blog-words',
|
|
54
|
+
)!;
|
|
55
|
+
|
|
56
|
+
expect(verdict('short_form').status).toBe('warn'); // 100 over 300–400
|
|
57
|
+
expect(verdict('short_form').detail).toBe('500 words — 100 over (300–400)');
|
|
58
|
+
expect(verdict('long_form').status).toBe('warn'); // 700 under 1200–2000
|
|
59
|
+
expect(verdict('plain_form').status).toBe('warn'); // 100 under 600–800
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('(d) an unknown or absent content type falls back — it never skips', () => {
|
|
64
|
+
const DEFAULT_BLOG_BAND = [400, 500];
|
|
65
|
+
|
|
66
|
+
it('an empty policy set resolves to the package defaults', () => {
|
|
67
|
+
expect(resolveCheckPolicy([], { contentType: 'anything' })).toEqual({});
|
|
68
|
+
const c = runContentChecks({ blog: words(450) }, resolveCheckPolicy([], {}));
|
|
69
|
+
expect(c.find((x) => x.id === 'blog-words')).toMatchObject({ status: 'pass' });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('an unknown content type resolves exactly what an absent one does', () => {
|
|
73
|
+
expect(blogBandOf(TENANT, 'a_type_nobody_declared')).toEqual(blogBandOf(TENANT, undefined));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('an unknown content type still RUNS the check, against today’s defaults', () => {
|
|
77
|
+
// The failure this guards: "we don't know your type, so we won't check" —
|
|
78
|
+
// a skipped check reads as a passing one on the rail (bd startsim-wncr6).
|
|
79
|
+
const checks = runContentChecks(
|
|
80
|
+
{ blog: words(506) },
|
|
81
|
+
resolveCheckPolicy(TENANT, { contentType: 'a_type_nobody_declared' }),
|
|
82
|
+
);
|
|
83
|
+
const blog = checks.find((c) => c.id === 'blog-words');
|
|
84
|
+
expect(blog).toBeDefined();
|
|
85
|
+
expect(blog!.status).toBe('warn');
|
|
86
|
+
// Judged against the shared defaults, unchanged from before this bead.
|
|
87
|
+
expect(blog!.detail).toBe(`506 words — 6 over (${DEFAULT_BLOG_BAND[0]}–${DEFAULT_BLOG_BAND[1]})`);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('does not throw on a null-ish or empty content type', () => {
|
|
91
|
+
expect(() => resolveCheckPolicy(TENANT, {})).not.toThrow();
|
|
92
|
+
expect(() => resolveCheckPolicy(TENANT, { contentType: '' })).not.toThrow();
|
|
93
|
+
expect(() => resolveCheckPolicy(TENANT, { contentType: undefined })).not.toThrow();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('the language dimension (bd startsim-wn2p.29 needs this seam, not its numbers)', () => {
|
|
98
|
+
const BILINGUAL: CheckPolicySet = [
|
|
99
|
+
{ contentType: 'short_form', policy: { blogWords: [300, 400] } },
|
|
100
|
+
{ language: 'zz', policy: { blogWords: [900, 1000] } },
|
|
101
|
+
{ contentType: 'short_form', language: 'zz', policy: { blogWords: [700, 800] } },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
it('a language rule applies across content types', () => {
|
|
105
|
+
expect(blogBandOf(BILINGUAL, 'other_form', 'zz')).toEqual([900, 1000]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('matches on the primary subtag, so zz-XX picks up the zz rule', () => {
|
|
109
|
+
expect(blogBandOf(BILINGUAL, 'other_form', 'zz-XX')).toEqual([900, 1000]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('a rule naming BOTH beats a rule naming one, whatever the declaration order', () => {
|
|
113
|
+
expect(blogBandOf(BILINGUAL, 'short_form', 'zz')).toEqual([700, 800]);
|
|
114
|
+
// and again with the two-matcher rule declared FIRST, where plain
|
|
115
|
+
// last-one-wins would have let the single-matcher rules override it
|
|
116
|
+
const reversed: CheckPolicySet = [...BILINGUAL].reverse();
|
|
117
|
+
expect(blogBandOf(reversed, 'short_form', 'zz')).toEqual([700, 800]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('two SINGLE-dimension rules rank equal, so the tenant’s declaration order decides', () => {
|
|
121
|
+
// The case the tenant will actually hit: a Chinese evergreen matches both a
|
|
122
|
+
// content-type rule and a language rule. They do not combine — one wins
|
|
123
|
+
// outright — and this package must not be the thing that decides which of a
|
|
124
|
+
// tenant's two editorial axes outranks the other.
|
|
125
|
+
const typeThenLang: CheckPolicySet = [
|
|
126
|
+
{ contentType: 'long_form', policy: { blogWords: [1200, 2000] } },
|
|
127
|
+
{ language: 'zz', policy: { blogWords: [500, 620] } },
|
|
128
|
+
];
|
|
129
|
+
expect(blogBandOf(typeThenLang, 'long_form', 'zz')).toEqual([500, 620]);
|
|
130
|
+
|
|
131
|
+
const langThenType: CheckPolicySet = [...typeThenLang].reverse();
|
|
132
|
+
expect(blogBandOf(langThenType, 'long_form', 'zz')).toEqual([1200, 2000]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('a combination gets its own band only when the tenant declares the combination', () => {
|
|
136
|
+
// Documented consequence: two independent rules never blend into a third.
|
|
137
|
+
// The right band for a Chinese evergreen is neither the English evergreen
|
|
138
|
+
// number nor the Chinese brief number (bd startsim-wn2p.29).
|
|
139
|
+
const withCross: CheckPolicySet = [
|
|
140
|
+
{ contentType: 'long_form', policy: { blogWords: [1200, 2000] } },
|
|
141
|
+
{ language: 'zz', policy: { blogWords: [500, 620] } },
|
|
142
|
+
{ contentType: 'long_form', language: 'zz', policy: { blogWords: [1500, 2400] } },
|
|
143
|
+
];
|
|
144
|
+
expect(blogBandOf(withCross, 'long_form', 'zz')).toEqual([1500, 2400]);
|
|
145
|
+
// …and the single-dimension rules still answer on their own.
|
|
146
|
+
expect(blogBandOf(withCross, 'long_form', 'en')).toEqual([1200, 2000]);
|
|
147
|
+
expect(blogBandOf(withCross, 'other_form', 'zz')).toEqual([500, 620]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('a language nobody declared falls back to the content type’s band', () => {
|
|
151
|
+
expect(blogBandOf(BILINGUAL, 'short_form', 'qq')).toEqual([300, 400]);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('merge semantics — spelled out because an unspoken one skips checks silently', () => {
|
|
156
|
+
it('a more specific rule overrides only the keys it names', () => {
|
|
157
|
+
const set: CheckPolicySet = [
|
|
158
|
+
{ policy: { blogWords: [100, 200], headlineWords: [3, 4] } },
|
|
159
|
+
{ contentType: 'short_form', policy: { blogWords: [300, 400] } },
|
|
160
|
+
];
|
|
161
|
+
const resolved = resolveCheckPolicy(set, { contentType: 'short_form' });
|
|
162
|
+
expect(resolved.blogWords).toEqual([300, 400]);
|
|
163
|
+
// headlineWords was never mentioned by the specific rule, so it survives.
|
|
164
|
+
expect(resolved.headlineWords).toEqual([3, 4]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('an inherited `checks` allow-list CARRIES THROUGH a rule that does not mention it', () => {
|
|
168
|
+
// Decided, not accidental: a rule leaves untouched every key it does not
|
|
169
|
+
// name. So a tenant narrowing the check set tenant-wide keeps that narrowing
|
|
170
|
+
// when it later adds a per-type band. To widen it again, say so explicitly.
|
|
171
|
+
const set: CheckPolicySet = [
|
|
172
|
+
{ policy: { checks: ['blog-words'] } },
|
|
173
|
+
{ contentType: 'short_form', policy: { blogWords: [300, 400] } },
|
|
174
|
+
];
|
|
175
|
+
expect(resolveCheckPolicy(set, { contentType: 'short_form' }).checks).toEqual(['blog-words']);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('a declared `checks` list REPLACES an inherited one wholesale, it does not union', () => {
|
|
179
|
+
const set: CheckPolicySet = [
|
|
180
|
+
{ policy: { checks: ['blog-words'] } },
|
|
181
|
+
{ contentType: 'short_form', policy: { checks: ['headline-words'] } },
|
|
182
|
+
];
|
|
183
|
+
expect(resolveCheckPolicy(set, { contentType: 'short_form' }).checks).toEqual(['headline-words']);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('which checks run is configuration too (bd startsim-wncr6 part 2)', () => {
|
|
188
|
+
const fields = { blog: words(450), headline: words(10), tags: 'a,b,c,d,e' };
|
|
189
|
+
|
|
190
|
+
it('with no allow-list, every check the fields support still runs', () => {
|
|
191
|
+
const ids = runContentChecks(fields, resolveCheckPolicy([], {})).map((c) => c.id).sort();
|
|
192
|
+
expect(ids).toEqual(['blog-words', 'headline-words', 'no-hype', 'tags-count']);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('a tenant that does not care about word count can say so', () => {
|
|
196
|
+
// Quinn, 2026-08-25: "Is word count a check that we even care about based on
|
|
197
|
+
// what we're doing? … They don't have to be blog article checks."
|
|
198
|
+
const set: CheckPolicySet = [{ policy: { checks: ['no-hype', 'tags-count'] } }];
|
|
199
|
+
const ids = runContentChecks(fields, resolveCheckPolicy(set, {})).map((c) => c.id).sort();
|
|
200
|
+
expect(ids).toEqual(['no-hype', 'tags-count']);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('the allow-list is per content type, like the bands', () => {
|
|
204
|
+
const set: CheckPolicySet = [
|
|
205
|
+
{ policy: { checks: ['blog-words', 'headline-words', 'no-hype', 'tags-count'] } },
|
|
206
|
+
{ contentType: 'short_form', policy: { checks: ['headline-words'] } },
|
|
207
|
+
];
|
|
208
|
+
expect(runContentChecks(fields, resolveCheckPolicy(set, { contentType: 'short_form' })).map((c) => c.id))
|
|
209
|
+
.toEqual(['headline-words']);
|
|
210
|
+
expect(runContentChecks(fields, resolveCheckPolicy(set, { contentType: 'other_form' })).map((c) => c.id).sort())
|
|
211
|
+
.toEqual(['blog-words', 'headline-words', 'no-hype', 'tags-count']);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('an empty allow-list runs NOTHING, and is distinct from an absent one', () => {
|
|
215
|
+
// `[]` is a tenant saying "no deterministic checks", which is a real answer.
|
|
216
|
+
// `undefined` is "you decide", which means all of them. They must not be the
|
|
217
|
+
// same value — the approved-sources check learned that the hard way
|
|
218
|
+
// (bd startsim-4ipm: `approvedHosts: []` failed every host on earth).
|
|
219
|
+
expect(runContentChecks(fields, { checks: [] })).toEqual([]);
|
|
220
|
+
expect(runContentChecks(fields, {}).length).toBeGreaterThan(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -44,7 +44,7 @@ describe('runContentChecks — field presence', () => {
|
|
|
44
44
|
describe('word-count bands', () => {
|
|
45
45
|
it('blog: in-band 400–500 passes, outside warns, with a "N words" detail', () => {
|
|
46
46
|
expect(find(runContentChecks({ blog: words(450) }), 'blog-words')).toMatchObject({ status: 'pass', detail: '450 words' });
|
|
47
|
-
expect(find(runContentChecks({ blog: words(300) }), 'blog-words')).toMatchObject({ status: 'warn', detail: '300 words' });
|
|
47
|
+
expect(find(runContentChecks({ blog: words(300) }), 'blog-words')).toMatchObject({ status: 'warn', detail: '300 words — 100 under (400–500)' });
|
|
48
48
|
expect(find(runContentChecks({ blog: words(600) }), 'blog-words')!.status).toBe('warn');
|
|
49
49
|
// inclusive edges
|
|
50
50
|
expect(find(runContentChecks({ blog: words(400) }), 'blog-words')!.status).toBe('pass');
|
|
@@ -341,3 +341,56 @@ describe('source-overlap check', () => {
|
|
|
341
341
|
expect(c!.status).toBe('pass');
|
|
342
342
|
});
|
|
343
343
|
});
|
|
344
|
+
|
|
345
|
+
describe('bd startsim-4muob (a)/(b) — a band check says which way it missed', () => {
|
|
346
|
+
/**
|
|
347
|
+
* THE MEETING, 2026-08-25. The rail read "Blog word count — 506 words" as a
|
|
348
|
+
* notice. In the room Quinn guessed the band was a ~800-word MINIMUM and
|
|
349
|
+
* offered to lower it; Malin pushed back that briefs are 400–500. The band was
|
|
350
|
+
* already [400, 500] and the draft was SIX WORDS OVER THE TOP. Nobody present
|
|
351
|
+
* could tell which direction it had missed — including the author of the check.
|
|
352
|
+
*
|
|
353
|
+
* A flag a reviewer cannot act on spends the goodwill the rail runs on.
|
|
354
|
+
*/
|
|
355
|
+
it('(a) states the overshoot and the band when the count is OVER', () => {
|
|
356
|
+
expect(find(runContentChecks({ blog: words(506) }), 'blog-words')).toMatchObject({
|
|
357
|
+
status: 'warn',
|
|
358
|
+
detail: '506 words — 6 over (400–500)',
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('(a) states the shortfall and the band when the count is UNDER', () => {
|
|
363
|
+
expect(find(runContentChecks({ blog: words(312) }), 'blog-words')).toMatchObject({
|
|
364
|
+
status: 'warn',
|
|
365
|
+
detail: '312 words — 88 under (400–500)',
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('(b) an in-band check does not editorialise — it is still a bare count', () => {
|
|
370
|
+
// Green rows are read at a glance and never acted on. Spending the narrow
|
|
371
|
+
// rail column on "450 words — in the 400–500 band" says nothing new.
|
|
372
|
+
expect(find(runContentChecks({ blog: words(450) }), 'blog-words')!.detail).toBe('450 words');
|
|
373
|
+
expect(find(runContentChecks({ blog: words(400) }), 'blog-words')!.detail).toBe('400 words');
|
|
374
|
+
expect(find(runContentChecks({ blog: words(500) }), 'blog-words')!.detail).toBe('500 words');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('(a) applies to EVERY band check, not just the blog one', () => {
|
|
378
|
+
// headline-words, linkedin-words, tags-count and meta-length all ran the
|
|
379
|
+
// same bandStatus + bare-detail pattern, so all four missed the same way.
|
|
380
|
+
expect(find(runContentChecks({ headline: words(5) }), 'headline-words')!.detail)
|
|
381
|
+
.toBe('5 words — 3 under (8–12)');
|
|
382
|
+
expect(find(runContentChecks({ linkedin: words(300) }), 'linkedin-words')!.detail)
|
|
383
|
+
.toBe('300 words — 50 over (150–250)');
|
|
384
|
+
expect(find(runContentChecks({ tags: 'a,b' }), 'tags-count')!.detail)
|
|
385
|
+
.toBe('2 tags — 2 under (4–6)');
|
|
386
|
+
expect(find(runContentChecks({ metaDescription: 'x'.repeat(200) }), 'meta-length')!.detail)
|
|
387
|
+
.toBe('200 chars — 40 over (150–160)');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('(a) reports the band it was ACTUALLY judged against, not the default', () => {
|
|
391
|
+
// The bug's other half: a reviewer told "(400–500)" when the tenant configured
|
|
392
|
+
// something else would be misled twice over.
|
|
393
|
+
expect(find(runContentChecks({ blog: words(900) }, { blogWords: [1000, 1200] }), 'blog-words')!.detail)
|
|
394
|
+
.toBe('900 words — 100 under (1000–1200)');
|
|
395
|
+
});
|
|
396
|
+
});
|
|
@@ -69,12 +69,51 @@ export interface ContentFields {
|
|
|
69
69
|
/** An inclusive `[min, max]` band. */
|
|
70
70
|
export type Band = [min: number, max: number];
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Every check this package implements, in the order `runContentChecks` emits
|
|
74
|
+
* them. Exported so a tenant can name the ones it wants (see `checks` below)
|
|
75
|
+
* and so a typo in that list is a compile error rather than a silently empty
|
|
76
|
+
* rail.
|
|
77
|
+
*
|
|
78
|
+
* A tenant's OWN checks are not in here and do not belong in here: they are
|
|
79
|
+
* computed by the tenant and appended to the returned array, the way
|
|
80
|
+
* marketing-agents already appends its approved-source stand-in.
|
|
81
|
+
*/
|
|
82
|
+
export const CONTENT_CHECK_IDS = [
|
|
83
|
+
'headline-words',
|
|
84
|
+
'blog-words',
|
|
85
|
+
'linkedin-words',
|
|
86
|
+
'tags-count',
|
|
87
|
+
'meta-length',
|
|
88
|
+
'link-placeholder',
|
|
89
|
+
'no-hype',
|
|
90
|
+
'approved-sources',
|
|
91
|
+
'source-overlap',
|
|
92
|
+
] as const;
|
|
93
|
+
|
|
94
|
+
/** The id of a check implemented by this package. */
|
|
95
|
+
export type ContentCheckId = (typeof CONTENT_CHECK_IDS)[number];
|
|
96
|
+
|
|
72
97
|
/**
|
|
73
98
|
* Tunable thresholds. Everything has a sensible default EXCEPT `approvedHosts`:
|
|
74
99
|
* the source allow-list is deliberately app-injected (no hardcoded list here),
|
|
75
100
|
* so the approved-sources check is skipped entirely when it is omitted.
|
|
76
101
|
*/
|
|
77
102
|
export interface ContentChecksConfig {
|
|
103
|
+
/**
|
|
104
|
+
* WHICH checks run. Omitted = every check the supplied fields support (the
|
|
105
|
+
* behaviour before bd startsim-wncr6, and the right default for a caller that
|
|
106
|
+
* has no opinion). Present = exactly these, so a tenant can answer "is word
|
|
107
|
+
* count a check we even care about?" with a list instead of a fork.
|
|
108
|
+
*
|
|
109
|
+
* `[]` runs nothing and is deliberately DISTINCT from omitting the key — the
|
|
110
|
+
* approved-source check learned that lesson the expensive way (bd
|
|
111
|
+
* startsim-4ipm: `approvedHosts: []` is "fail every host", not "skip").
|
|
112
|
+
*
|
|
113
|
+
* Normally resolved per content type and language by `resolveCheckPolicy`
|
|
114
|
+
* (./policy) rather than written out here.
|
|
115
|
+
*/
|
|
116
|
+
checks?: readonly ContentCheckId[];
|
|
78
117
|
/** Blog word-count band (default 400–500). */
|
|
79
118
|
blogWords?: Band;
|
|
80
119
|
/** LinkedIn word-count band (default 150–250). */
|
|
@@ -227,6 +266,30 @@ function bandStatus(n: number, [min, max]: Band): CheckStatus {
|
|
|
227
266
|
return n >= min && n <= max ? 'pass' : 'warn';
|
|
228
267
|
}
|
|
229
268
|
|
|
269
|
+
/**
|
|
270
|
+
* A band check's `detail`, which now says which way it missed (bd startsim-ztlz7).
|
|
271
|
+
*
|
|
272
|
+
* WHY. On 2026-08-25 the quality rail showed "Blog word count — 506 words" as a
|
|
273
|
+
* notice in front of a customer. The band was already [400, 500] and the draft
|
|
274
|
+
* was SIX WORDS OVER THE TOP — but the row said only "506 words", so the guess in
|
|
275
|
+
* the room was that the band was an ~800-word MINIMUM, and an offer was made to
|
|
276
|
+
* lower it. The author of the check could not read his own check either. A
|
|
277
|
+
* number without its band is not a finding; it is a number.
|
|
278
|
+
*
|
|
279
|
+
* IN-BAND STAYS BARE. A green row is read at a glance and never acted on;
|
|
280
|
+
* spending the rail's narrow column on "450 words — in the 400–500 band" tells a
|
|
281
|
+
* reviewer nothing they were about to do something about.
|
|
282
|
+
*
|
|
283
|
+
* The band printed is the band the check was ACTUALLY judged against, so a tenant
|
|
284
|
+
* running its own numbers (./policy) is never shown the package default.
|
|
285
|
+
*/
|
|
286
|
+
function bandDetail(n: number, [min, max]: Band, unit: string): string {
|
|
287
|
+
const count = `${n} ${unit}`;
|
|
288
|
+
if (n < min) return `${count} — ${min - n} under (${min}–${max})`;
|
|
289
|
+
if (n > max) return `${count} — ${n - max} over (${min}–${max})`;
|
|
290
|
+
return count;
|
|
291
|
+
}
|
|
292
|
+
|
|
230
293
|
/**
|
|
231
294
|
* `locations` for a whole-field problem (word/char counts, a missing
|
|
232
295
|
* placeholder): the field itself is the finding, so there is no span to
|
|
@@ -259,6 +322,7 @@ export function hostOf(url: string): string {
|
|
|
259
322
|
export function runContentChecks(fields: ContentFields, config: ContentChecksConfig = {}): ContentCheck[] {
|
|
260
323
|
const checks: ContentCheck[] = [];
|
|
261
324
|
const {
|
|
325
|
+
checks: enabled,
|
|
262
326
|
blogWords = DEFAULTS.blogWords,
|
|
263
327
|
linkedinWords = DEFAULTS.linkedinWords,
|
|
264
328
|
headlineWords = DEFAULTS.headlineWords,
|
|
@@ -273,39 +337,52 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
|
|
|
273
337
|
overlapFailRatio = 0.25,
|
|
274
338
|
} = config;
|
|
275
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Is this check switched on? No allow-list = all of them, so every existing
|
|
342
|
+
* caller is unaffected.
|
|
343
|
+
*
|
|
344
|
+
* Note what this does NOT do: it never drops a check because something about
|
|
345
|
+
* the draft was unrecognised. A check the tenant did not ask for is absent
|
|
346
|
+
* because the tenant said so; a check that could not be COMPUTED must be
|
|
347
|
+
* surfaced by the caller as its own not-checked row, never left out (bd
|
|
348
|
+
* startsim-4ipm, and the reason `resolveCheckPolicy` falls back rather than
|
|
349
|
+
* skips on an unknown content type).
|
|
350
|
+
*/
|
|
351
|
+
const runs = (id: ContentCheckId): boolean => enabled === undefined || enabled.includes(id);
|
|
352
|
+
|
|
276
353
|
// Word-count bands.
|
|
277
|
-
if (fields.headline !== undefined) {
|
|
354
|
+
if (runs('headline-words') && fields.headline !== undefined) {
|
|
278
355
|
const n = wordCount(fields.headline);
|
|
279
356
|
const status = bandStatus(n, headlineWords);
|
|
280
|
-
checks.push({ id: 'headline-words', label: 'Headline length', status, detail:
|
|
357
|
+
checks.push({ id: 'headline-words', label: 'Headline length', status, detail: bandDetail(n, headlineWords, 'words'), ...fieldLocation(status, 'headline') });
|
|
281
358
|
}
|
|
282
|
-
if (fields.blog !== undefined) {
|
|
359
|
+
if (runs('blog-words') && fields.blog !== undefined) {
|
|
283
360
|
const n = wordCount(fields.blog);
|
|
284
361
|
const status = bandStatus(n, blogWords);
|
|
285
|
-
checks.push({ id: 'blog-words', label: 'Blog word count', status, detail:
|
|
362
|
+
checks.push({ id: 'blog-words', label: 'Blog word count', status, detail: bandDetail(n, blogWords, 'words'), ...fieldLocation(status, 'blog') });
|
|
286
363
|
}
|
|
287
|
-
if (fields.linkedin !== undefined) {
|
|
364
|
+
if (runs('linkedin-words') && fields.linkedin !== undefined) {
|
|
288
365
|
const n = wordCount(fields.linkedin);
|
|
289
366
|
const status = bandStatus(n, linkedinWords);
|
|
290
|
-
checks.push({ id: 'linkedin-words', label: 'LinkedIn word count', status, detail:
|
|
367
|
+
checks.push({ id: 'linkedin-words', label: 'LinkedIn word count', status, detail: bandDetail(n, linkedinWords, 'words'), ...fieldLocation(status, 'linkedin') });
|
|
291
368
|
}
|
|
292
369
|
|
|
293
370
|
// Tag count.
|
|
294
|
-
if (fields.tags !== undefined) {
|
|
371
|
+
if (runs('tags-count') && fields.tags !== undefined) {
|
|
295
372
|
const n = tagCount(fields.tags);
|
|
296
373
|
const status = bandStatus(n, tagsCount);
|
|
297
|
-
checks.push({ id: 'tags-count', label: 'Tag count', status, detail:
|
|
374
|
+
checks.push({ id: 'tags-count', label: 'Tag count', status, detail: bandDetail(n, tagsCount, 'tags'), ...fieldLocation(status, 'tags') });
|
|
298
375
|
}
|
|
299
376
|
|
|
300
377
|
// Meta description length (characters).
|
|
301
|
-
if (fields.metaDescription !== undefined) {
|
|
378
|
+
if (runs('meta-length') && fields.metaDescription !== undefined) {
|
|
302
379
|
const len = fields.metaDescription.trim().length;
|
|
303
380
|
const status = bandStatus(len, metaChars);
|
|
304
|
-
checks.push({ id: 'meta-length', label: 'Meta description length', status, detail:
|
|
381
|
+
checks.push({ id: 'meta-length', label: 'Meta description length', status, detail: bandDetail(len, metaChars, 'chars'), ...fieldLocation(status, 'metaDescription') });
|
|
305
382
|
}
|
|
306
383
|
|
|
307
384
|
// `[link]` placeholder present in the LinkedIn post.
|
|
308
|
-
if (requireLink && fields.linkedin !== undefined) {
|
|
385
|
+
if (requireLink && runs('link-placeholder') && fields.linkedin !== undefined) {
|
|
309
386
|
const has = fields.linkedin.includes('[link]');
|
|
310
387
|
const status: CheckStatus = has ? 'pass' : 'warn';
|
|
311
388
|
checks.push({
|
|
@@ -326,7 +403,7 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
|
|
|
326
403
|
['linkedin', fields.linkedin],
|
|
327
404
|
];
|
|
328
405
|
const hasProse = proseFields.some(([, text]) => text !== undefined);
|
|
329
|
-
if (hasProse) {
|
|
406
|
+
if (hasProse && runs('no-hype')) {
|
|
330
407
|
const haystack = proseFields
|
|
331
408
|
.map(([, text]) => text)
|
|
332
409
|
.filter((s): s is string => typeof s === 'string')
|
|
@@ -354,7 +431,7 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
|
|
|
354
431
|
}
|
|
355
432
|
|
|
356
433
|
// Approved sources — only when the app supplied an allow-list.
|
|
357
|
-
if (approvedHosts !== undefined) {
|
|
434
|
+
if (approvedHosts !== undefined && runs('approved-sources')) {
|
|
358
435
|
const approved = approvedHosts.map((h) => h.toLowerCase().replace(/^www\./, ''));
|
|
359
436
|
/**
|
|
360
437
|
* A host is approved when it IS an approved domain or is a SUBDOMAIN of one.
|
|
@@ -400,7 +477,7 @@ export function runContentChecks(fields: ContentFields, config: ContentChecksCon
|
|
|
400
477
|
// Verbatim overlap — the anti-plagiarism guardrail. Only when the caller supplied
|
|
401
478
|
// the cited source article TEXT. Scans blog + linkedin against the sources and
|
|
402
479
|
// reports the WORST offender (longest copied run / highest copied fraction).
|
|
403
|
-
if (sourceTexts !== undefined && sourceTexts.length > 0) {
|
|
480
|
+
if (sourceTexts !== undefined && sourceTexts.length > 0 && runs('source-overlap')) {
|
|
404
481
|
const targets: Array<[keyof ContentFields, string | undefined]> = [
|
|
405
482
|
['blog', fields.blog],
|
|
406
483
|
['linkedin', fields.linkedin],
|
|
@@ -20,6 +20,8 @@ export {
|
|
|
20
20
|
extractUrls,
|
|
21
21
|
hostOf,
|
|
22
22
|
DEFAULT_HYPE_WORDS,
|
|
23
|
+
CONTENT_CHECK_IDS,
|
|
24
|
+
type ContentCheckId,
|
|
23
25
|
type ContentCheck,
|
|
24
26
|
type CheckLocation,
|
|
25
27
|
type ContentFields,
|
|
@@ -30,6 +32,19 @@ export {
|
|
|
30
32
|
|
|
31
33
|
export { verbatimOverlap, normWords, type OverlapResult } from './overlap';
|
|
32
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Which checks run and with what bands, resolved per content type and language.
|
|
37
|
+
* A tenant declares the policy; this package keeps the implementations and the
|
|
38
|
+
* defaults (bd startsim-wncr6).
|
|
39
|
+
*/
|
|
40
|
+
export {
|
|
41
|
+
resolveCheckPolicy,
|
|
42
|
+
type CheckPolicy,
|
|
43
|
+
type CheckPolicyRule,
|
|
44
|
+
type CheckPolicySet,
|
|
45
|
+
type CheckContext,
|
|
46
|
+
} from './policy';
|
|
47
|
+
|
|
33
48
|
export { ValidationChecklist } from './ValidationChecklist';
|
|
34
49
|
export type {
|
|
35
50
|
ValidationChecklistProps,
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which checks run, and against what thresholds — resolved per content type and
|
|
3
|
+
* per language (bd startsim-wncr6). Pure + framework-free, like `checks.ts`.
|
|
4
|
+
*
|
|
5
|
+
* ── THE BUG THIS EXISTS FOR ──────────────────────────────────────────────────
|
|
6
|
+
* `ContentChecksConfig` has always taken per-call band overrides, but nothing
|
|
7
|
+
* ever resolved them from the `content_type` the call site had in hand. So a
|
|
8
|
+
* weekly brief ("short, current news") and a long-form evergreen guide ("much
|
|
9
|
+
* more in-depth, much bigger scope") were both judged against one 400–500 word
|
|
10
|
+
* band, and the same single band was applied to Chinese translations, where a
|
|
11
|
+
* faithful rendering of a 507-word English brief segments to ~609 words and
|
|
12
|
+
* therefore fails forever (bd startsim-wn2p.29).
|
|
13
|
+
*
|
|
14
|
+
* ── THE REQUIREMENT, WHICH IS BIGGER THAN THRESHOLDS ─────────────────────────
|
|
15
|
+
* Quinn, 2026-08-25: "Is word count a check that we even care about based on
|
|
16
|
+
* what we're doing? Or is it something else — rank how sensational we think it
|
|
17
|
+
* is, or how many source data pieces we have. They don't have to be blog
|
|
18
|
+
* article checks. They can be OGMC-specific checks that you guys want."
|
|
19
|
+
*
|
|
20
|
+
* So the CHECK SET is configuration too, not only its numbers. A rule may name
|
|
21
|
+
* `checks` as well as bands; this package supplies the implementations and the
|
|
22
|
+
* defaults, a tenant supplies the policy.
|
|
23
|
+
*
|
|
24
|
+
* ── TWO THINGS THIS FILE MUST NOT BECOME ─────────────────────────────────────
|
|
25
|
+
* 1. A HOME FOR ONE CUSTOMER'S EDITORIAL STANDARDS. There is not a single
|
|
26
|
+
* content_type value, locale or band in this file, and there must never be.
|
|
27
|
+
* @startsimpli/ui ships to every tenant; OGMC's `weekly_brief` band is
|
|
28
|
+
* OGMC's, declared in marketing-agents-web. The mirror of that rule is just
|
|
29
|
+
* as binding: a tenant must not fork a private copy of the CHECKS either —
|
|
30
|
+
* bd startsim-wn2p.28 cost four PRs because one word count had been written
|
|
31
|
+
* in four places.
|
|
32
|
+
* 2. A WAY TO SKIP A CHECK BY ACCIDENT. An unknown content type resolves to
|
|
33
|
+
* whatever the less specific rules said, ending at this package's defaults.
|
|
34
|
+
* It never resolves to "no check": a check that silently vanishes reads on
|
|
35
|
+
* the rail exactly like a check that passed. The sibling failure mode, from
|
|
36
|
+
* the same epic, is a band that CANNOT be satisfied by correct output — a
|
|
37
|
+
* permanent red trains reviewers to ignore the whole rail, which is why the
|
|
38
|
+
* language dimension exists rather than one band for every script.
|
|
39
|
+
*/
|
|
40
|
+
import type { ContentChecksConfig } from './checks';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A tenant's standing policy for one situation: which checks run, and their
|
|
44
|
+
* thresholds.
|
|
45
|
+
*
|
|
46
|
+
* `approvedHosts` and `sourceTexts` are deliberately NOT part of a policy —
|
|
47
|
+
* they are facts about the draft in front of you (the tenant's live source
|
|
48
|
+
* records, the cited articles' text), resolved per render and merged in by the
|
|
49
|
+
* caller. Standing policy and per-draft evidence are different things and a
|
|
50
|
+
* tenant that confuses them ends up caching one as the other.
|
|
51
|
+
*/
|
|
52
|
+
export type CheckPolicy = Omit<ContentChecksConfig, 'approvedHosts' | 'sourceTexts'>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One rule: a policy plus the situations it applies to. Omit both matchers and
|
|
56
|
+
* the rule is the tenant-wide baseline.
|
|
57
|
+
*
|
|
58
|
+
* Matching is deliberately dumb — exact `content_type` values and a language
|
|
59
|
+
* subtag — because the values are the tenant's own schema enum and its own
|
|
60
|
+
* locale codes. This package knows none of them and must not pretend to.
|
|
61
|
+
*/
|
|
62
|
+
export interface CheckPolicyRule {
|
|
63
|
+
/**
|
|
64
|
+
* The `content_type` value(s) this rule applies to, matched exactly (after a
|
|
65
|
+
* trim) against the tenant's own enum. Omitted = any content type.
|
|
66
|
+
*/
|
|
67
|
+
contentType?: string | readonly string[];
|
|
68
|
+
/**
|
|
69
|
+
* The language(s) this rule applies to, as BCP-47 tags matched on the primary
|
|
70
|
+
* subtag: a rule for `zh` applies to `zh`, `zh-CN` and `zh-Hans-CN`; a rule
|
|
71
|
+
* for `zh-Hans` applies to `zh-Hans-CN` but not to bare `zh`. Case-insensitive.
|
|
72
|
+
* Omitted = any language.
|
|
73
|
+
*/
|
|
74
|
+
language?: string | readonly string[];
|
|
75
|
+
/** What to apply when this rule matches. */
|
|
76
|
+
policy: CheckPolicy;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A tenant's whole policy, as an ordered list of rules. */
|
|
80
|
+
export type CheckPolicySet = readonly CheckPolicyRule[];
|
|
81
|
+
|
|
82
|
+
/** What is being checked, as far as policy is concerned. */
|
|
83
|
+
export interface CheckContext {
|
|
84
|
+
/** The draft's `content_type`, straight off the record. Unknown values are fine. */
|
|
85
|
+
contentType?: string | null;
|
|
86
|
+
/** The draft's language as a BCP-47 tag (`en`, `zh-CN`). Unknown values are fine. */
|
|
87
|
+
language?: string | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Normalise a matcher to a list of non-empty trimmed strings; null = "matches anything". */
|
|
91
|
+
function matcherList(value: string | readonly string[] | undefined): string[] | null {
|
|
92
|
+
if (value === undefined) return null;
|
|
93
|
+
const list = Array.isArray(value) ? [...value] : [value as string];
|
|
94
|
+
return list.map((v) => String(v).trim()).filter(Boolean);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A language tag matches a rule when it IS the rule's tag or is a more specific
|
|
99
|
+
* form of it. The hyphen is load-bearing for the same reason it is in the
|
|
100
|
+
* approved-host check: without it `zho` would match a rule written for `zh`.
|
|
101
|
+
*/
|
|
102
|
+
function languageMatches(ruleTag: string, contextTag: string): boolean {
|
|
103
|
+
const rule = ruleTag.toLowerCase();
|
|
104
|
+
const ctx = contextTag.toLowerCase();
|
|
105
|
+
return ctx === rule || ctx.startsWith(`${rule}-`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Does this rule apply? A matcher the context cannot answer simply does not match. */
|
|
109
|
+
function ruleApplies(rule: CheckPolicyRule, context: CheckContext): boolean {
|
|
110
|
+
const types = matcherList(rule.contentType);
|
|
111
|
+
if (types !== null) {
|
|
112
|
+
const contentType = (context.contentType ?? '').trim();
|
|
113
|
+
if (!contentType || !types.includes(contentType)) return false;
|
|
114
|
+
}
|
|
115
|
+
const languages = matcherList(rule.language);
|
|
116
|
+
if (languages !== null) {
|
|
117
|
+
const language = (context.language ?? '').trim();
|
|
118
|
+
if (!language || !languages.some((tag) => languageMatches(tag, language))) return false;
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* How specific a rule is: simply HOW MANY dimensions it names. Naming both
|
|
125
|
+
* outranks naming either, so a tenant can write its rules in whatever order
|
|
126
|
+
* reads best and still get the answer it meant.
|
|
127
|
+
*
|
|
128
|
+
* The two single-dimension rules rank EQUAL on purpose. Ranking content type
|
|
129
|
+
* above language (or the reverse) would mean that, for a Chinese evergreen
|
|
130
|
+
* draft, a rule for `lead_magnet` silently swallowed a rule for `zh` — and this
|
|
131
|
+
* package has no business deciding which of a tenant's two editorial axes is the
|
|
132
|
+
* more important one. Between equals the later declaration wins, so the tenant
|
|
133
|
+
* says which, in the order it writes them.
|
|
134
|
+
*/
|
|
135
|
+
function specificity(rule: CheckPolicyRule): number {
|
|
136
|
+
return (rule.contentType !== undefined ? 1 : 0) + (rule.language !== undefined ? 1 : 0);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve a tenant's policy for one draft into the config `runContentChecks`
|
|
141
|
+
* takes. Pure. Never throws on an unrecognised content type or language.
|
|
142
|
+
*
|
|
143
|
+
* MERGE SEMANTICS, spelled out because leaving them unspoken is how a check goes
|
|
144
|
+
* missing: matching rules are applied least-specific first, and each one
|
|
145
|
+
* overrides ONLY the keys it names. A key a rule does not mention keeps whatever
|
|
146
|
+
* a less specific rule set, ending at this package's defaults.
|
|
147
|
+
*
|
|
148
|
+
* TWO SINGLE-DIMENSION RULES DO NOT COMBINE, and this is the sharp edge. A rule
|
|
149
|
+
* for content type `lead_magnet` and a rule for language `zh` both match a
|
|
150
|
+
* Chinese evergreen draft, rank equal, and so the LATER one wins outright — the
|
|
151
|
+
* other's bands are not blended in. A tenant that wants a distinct number for a
|
|
152
|
+
* combination must declare that combination, which is what the two-matcher rule
|
|
153
|
+
* is for. Blending would be worse, not better: the right band for a Chinese
|
|
154
|
+
* evergreen is neither the English evergreen number nor the Chinese brief number,
|
|
155
|
+
* and quietly synthesising a third from them is how you get a band that correct
|
|
156
|
+
* output cannot satisfy (bd startsim-wn2p.29).
|
|
157
|
+
*
|
|
158
|
+
* That cuts both ways on the `checks` allow-list, and this is the deliberate
|
|
159
|
+
* half: a tenant that narrows the check set tenant-wide KEEPS that narrowing in
|
|
160
|
+
* a per-type rule that only adjusts a band. To widen it again, the specific rule
|
|
161
|
+
* says so — and when it does, its list REPLACES the inherited one outright
|
|
162
|
+
* rather than being unioned with it, so "these are the checks for evergreen" is
|
|
163
|
+
* readable as exactly that.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* // In the TENANT, never here — these are its enum values and its numbers.
|
|
167
|
+
* const POLICY: CheckPolicySet = [
|
|
168
|
+
* { contentType: 'weekly_brief', policy: { blogWords: [400, 500] } },
|
|
169
|
+
* { language: 'zh', policy: { metaChars: [60, 80] } },
|
|
170
|
+
* // A combination needs its own rule — the two above would not merge into it.
|
|
171
|
+
* { contentType: 'weekly_brief', language: 'zh', policy: { blogWords: [500, 620] } },
|
|
172
|
+
* ];
|
|
173
|
+
* runContentChecks(fields, {
|
|
174
|
+
* ...resolveCheckPolicy(POLICY, { contentType, language }),
|
|
175
|
+
* ...approvedSourceCheckConfig(basis), // per-draft evidence, not policy
|
|
176
|
+
* });
|
|
177
|
+
*/
|
|
178
|
+
export function resolveCheckPolicy(
|
|
179
|
+
policies: CheckPolicySet,
|
|
180
|
+
context: CheckContext = {},
|
|
181
|
+
): CheckPolicy {
|
|
182
|
+
const matching = (policies ?? [])
|
|
183
|
+
.map((rule, index) => ({ rule, index }))
|
|
184
|
+
.filter(({ rule }) => rule && ruleApplies(rule, context))
|
|
185
|
+
.sort((a, b) => specificity(a.rule) - specificity(b.rule) || a.index - b.index);
|
|
186
|
+
|
|
187
|
+
const resolved: Record<string, unknown> = {};
|
|
188
|
+
for (const { rule } of matching) {
|
|
189
|
+
for (const [key, value] of Object.entries(rule.policy ?? {})) {
|
|
190
|
+
// An absent key means "no opinion", so it must not erase what a less
|
|
191
|
+
// specific rule decided — otherwise spreading a partial object over a
|
|
192
|
+
// policy would silently reset half of it.
|
|
193
|
+
if (value !== undefined) resolved[key] = value;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return resolved as CheckPolicy;
|
|
197
|
+
}
|