@startsimpli/ui 0.4.108 → 0.4.109
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 +3 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.109",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -45,9 +45,9 @@
|
|
|
45
45
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
46
46
|
"react": "^18.0.0 || ^19.0.0",
|
|
47
47
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
48
|
+
"@startsimpli/api": "^0.5.58",
|
|
48
49
|
"@startsimpli/auth": "^0.4.35",
|
|
49
|
-
"@startsimpli/i18n": "^0.1.3"
|
|
50
|
-
"@startsimpli/api": "^0.5.58"
|
|
50
|
+
"@startsimpli/i18n": "^0.1.3"
|
|
51
51
|
},
|
|
52
52
|
"peerDependenciesMeta": {
|
|
53
53
|
"@startsimpli/api": {
|
|
@@ -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
|
+
}
|