@topmindspace/topmind-writing-skills 0.9.1 → 0.9.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.9.2] - 2026-10-31
2
+
3
+ - 宿主包运行时显式提供文章渲染、从源稿渲染、本地文本提取、长度评估和长度规则集评估。文章结果绑定所选稿与产物的 SHA-256,可选绑定创作快照指纹。
4
+ - 长度规则集只接受调用方传入的 JSON,先校验结构和日期再报告。本包不附带、也不启用真实平台规则。Unicode 码点上限不能代替 X 加权长度或编辑器里的最终核对。
5
+ - 八个技能的版本不变。
6
+
1
7
  ## [0.9.1] - 2026-10-28
2
8
 
3
9
  - 安装器新增 `install all`、`update` 与 `status`:整套首次安装只需一条命令,后续更新只处理已装技能,状态直接显示安装版本与包快照。
package/README.en.md CHANGED
@@ -30,6 +30,8 @@ Open a Markdown document and choose **Writing & delivery** from the article tool
30
30
 
31
31
  Built-in HTML/text typography requires no Python. Preview and copy use the same output; changed sources or images require regeneration. Diagnostics identify missing images, diagram source, extended containers and table paste limitations. AI images, PNG rendering and publishing require their respective tools. Copying does not publish; wording scores do not decide editorial quality.
32
32
 
33
+ The host pack also renders from source bytes, extracts local text from clipboard HTML, and evaluates a length rule set. The length entry accepts only caller-supplied JSON. This package does not ship or enable production platform rules. Fields, dates and reports are in [the length rule-set contract](docs/length-ruleset-v1.md). A Unicode code-point ceiling is not X weighted length and is not the target editor's final check. JavaScript and Python renderer differences are in [the capability matrix](docs/renderer-capability-matrix.md).
34
+
33
35
  About & updates tracks the application, core skills, independent writing pack and integrations separately. Writing resources can update independently; compiled renderer code follows the host release. Managed packs are isolated, local modifications block replacement and failed installs preserve the previous version. Supported Desktop installs can download an update and explicitly restart; manual downloads remain available when signing or the installation channel prevents automatic updates.
34
36
 
35
37
  ## More choices, concrete references
package/README.md CHANGED
@@ -30,6 +30,8 @@ npx @topmindspace/topmind-writing-skills@latest update # 以后只更新已
30
30
 
31
31
  内置 HTML/文本排版无需 Python,预览与复制同源;源稿或图片改变后需要重新生成。扩展容器、图表源码、缺图和平台表格粘贴限制会显示诊断。封面生图、PNG 和外部发布依赖实际工具,复制不等于已发布。审校不使用“AI 味分数”作为定稿门槛。
32
32
 
33
+ 宿主包运行时另外提供从源稿生成文章结果、从剪贴板 HTML 提取本地文本,以及长度规则集评估。长度入口只接受调用方传入的 JSON,本包不附带、也不启用真实平台规则。规则字段、日期和报告见 [长度规则集运行契约](docs/length-ruleset-v1.md)。Unicode 码点上限不能代替 X 加权长度或目标编辑器里的最终核对。JavaScript 与 Python 渲染器的差异见 [能力矩阵](docs/renderer-capability-matrix.md)。
34
+
33
35
  “关于与更新”分别展示 Desktop、核心技能、独立写作包和集成组件。写作资源可独立更新;编译进宿主的排版代码随宿主升级。受管包按身份隔离,覆盖本地定制会被阻止,失败保留旧版。Desktop 支持标准更新器下载后显式重启;签名、平台或渠道不支持时保留手动下载。
34
36
 
35
37
  ## 加选项,让经验帮助创作
package/lib/host-pack.cjs CHANGED
@@ -28,7 +28,17 @@ function buildHostPack(root) {
28
28
  if (!/^---\r?\n/u.test(raw) || !/^name:\s*/mu.test(raw)) throw new Error(`Invalid skill: ${id}`);
29
29
  return { id, name: id, description: meta.description || id, version: meta.version, packId: 'topmind-writing-skills' };
30
30
  });
31
- const manifest = { schema_version: 1, name: 'topmind-writing-skills', version: pkg.version, license: pkg.license, repository: pkg.repository, entry_files: ids.map(id => `${id}/SKILL.md`), files, runtime: { api: 1, entry: 'runtime/article-renderer.mjs', update: 'host-release' } };
31
+ const capabilities = [
32
+ { id: 'article.render', entry: 'runtime/article-renderer.mjs', export: 'prepareArticleResult' },
33
+ { id: 'article.renderFromSource', entry: 'runtime/article-renderer.mjs', export: 'prepareArticleResultFromSource' },
34
+ { id: 'article.localTextDocument', entry: 'runtime/article-renderer.mjs', export: 'articleLocalTextDocument' },
35
+ { id: 'length.evaluate', entry: 'runtime/length-rules.mjs', export: 'evaluateLengthRules' },
36
+ { id: 'length.ruleset.evaluate', entry: 'runtime/length-ruleset.mjs', export: 'evaluateLengthRuleSet' },
37
+ ];
38
+ for (const capability of capabilities) {
39
+ if (!Object.hasOwn(files, capability.entry)) throw new Error(`Missing runtime capability entry: ${capability.entry}`);
40
+ }
41
+ const manifest = { schema_version: 1, name: 'topmind-writing-skills', version: pkg.version, license: pkg.license, repository: pkg.repository, entry_files: ids.map(id => `${id}/SKILL.md`), files, runtime: { api: 1, entry: 'runtime/article-renderer.mjs', capabilities, update: 'host-release' } };
32
42
  const digest = hash(JSON.stringify(manifest));
33
43
  return { manifest: { ...manifest, digest }, snapshot: { id: manifest.name, version: pkg.version, source: 'https://github.com/topmindspace/topmind-writing-skills', license: pkg.license, digest, skills, resources } };
34
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topmindspace/topmind-writing-skills",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "TopMindSpace 写作技能合集 — 通过 npm / npx 安装技能(公众号长文、X 长文、干货短文、X 引流短帖、改稿与质检、封面图、长图海报)。只出稿与发布包,不代发。",
5
5
  "license": "MIT",
6
6
  "author": "TopMindspace",
@@ -1,9 +1,30 @@
1
1
  export type WritingPlatform = 'wechat' | 'x' | 'text';
2
2
  export type WritingTheme = 'minimal-ink' | 'tech-blue' | 'newsprint' | 'graphite';
3
3
  export const WRITING_THEMES: Record<WritingTheme, { ink: string; accent: string; paper: string; muted: string }>;
4
+ export const ARTICLE_RENDERER_PROVIDER: Readonly<{ id: string; version: string }>;
4
5
  export const WRITING_ACTIONS: ReadonlyArray<{ id: string; zh: string; en: string; prompt: string; promptEn: string }>;
5
6
  export function stripFrontmatter(value: string): string;
6
7
  export interface ArticleDelivery { platform: WritingPlatform; theme: WritingTheme; html: string; body: string; text: string; checklist: string; images: Array<{ source: string; alt: string; status: string }>; diagnostics: Array<{ code: string; source?: string }> }
7
8
  export function renderArticle(input: { markdown: string; platform?: WritingPlatform; theme?: WritingTheme; title?: string; images?: Record<string, string> }): ArticleDelivery;
8
9
  export function resolveArticleImage(sourcePath: string, reference: string): string;
9
10
  export function prepareArticle(input: { sourcePath: string; markdown: string; platform?: WritingPlatform; theme?: WritingTheme; title?: string }, readImage: (relative: string) => Promise<string>): Promise<ArticleDelivery>;
11
+ export interface ArticleResult {
12
+ schemaVersion: 1;
13
+ capabilityId: 'article.render';
14
+ provider: Readonly<{ id: string; version: string }>;
15
+ status: 'ok' | 'warning' | 'error';
16
+ input: { fingerprint: string | null; target?: { platform: WritingPlatform; theme: WritingTheme };
17
+ source?: { relativePath: string; sha256: string };
18
+ imageBytes?: Array<{ relativePath: string; sha256: string }>;
19
+ creationSnapshotFingerprint?: string; localTextTitle?: string | null;
20
+ renderParameters?: { theme: WritingTheme; title: string } };
21
+ artifacts: Array<{ role: 'preview' | 'clipboard-html' | 'source-markdown' | 'image-checklist' | 'local-clipboard-text'; format: string; content: string; sha256: string }>;
22
+ images: ArticleDelivery['images'];
23
+ diagnostics: Array<{ code: string; severity: 'warning' | 'error'; message: string; location?: { source: string } }>;
24
+ }
25
+ export function renderArticleResult(input?: { markdown?: string; platform?: WritingPlatform; theme?: WritingTheme; title?: string; sourcePath?: string; images?: Record<string, string> }): Promise<ArticleResult>;
26
+ export function prepareArticleResult(input: { sourcePath: string; markdown: string; platform?: WritingPlatform; theme?: WritingTheme; title?: string }, readImage: (relative: string) => Promise<string>): Promise<ArticleResult>;
27
+ export function prepareArticleResultFromSource(input: { sourcePath: string; sourceBytes: Uint8Array;
28
+ platform?: WritingPlatform; theme?: WritingTheme; title?: string; creationSnapshotFingerprint?: string },
29
+ readImage: (relative: string) => Promise<string>): Promise<ArticleResult>;
30
+ export function articleLocalTextDocument(result: ArticleResult): { text_kind: 'platform_text'; title?: string; body: string } | null;
@@ -1,10 +1,13 @@
1
1
  /** Portable host renderer. Canonical source: topmind-writing-skills. No IO or executable HTML. */
2
+ import { sha256, sha256Bytes } from './content-sha256.mjs';
2
3
  export const WRITING_THEMES = Object.freeze({
3
4
  'minimal-ink': { ink: '#252525', accent: '#955247', paper: '#ffffff', muted: '#666666' },
4
5
  'tech-blue': { ink: '#172033', accent: '#0369a1', paper: '#ffffff', muted: '#536174' },
5
6
  newsprint: { ink: '#24211c', accent: '#80502a', paper: '#fffdf8', muted: '#655f55' },
6
7
  graphite: { ink: '#222222', accent: '#444444', paper: '#ffffff', muted: '#666666' },
7
8
  });
9
+ /** Version of the renderer's output semantics, independent from the npm package version. */
10
+ export const ARTICLE_RENDERER_PROVIDER = Object.freeze({ id: 'topmind-writing-skills/article-renderer', version: '1.1.0' });
8
11
  export const WRITING_ACTIONS = Object.freeze([
9
12
  { id: 'topmind-revise', zh: '改稿', en: 'Revise', prompt: '按 topmind-revise 审校当前内容,保留事实、数字、归因和作者原意。' },
10
13
  { id: 'topmind-format', zh: '只排版', en: 'Format', prompt: '按 topmind-format 只调整当前内容的排版,不改写文字。' },
@@ -20,8 +23,17 @@ export function stripFrontmatter(value) {
20
23
  return String(value || '').replace(/^\uFEFF/u, '').replace(/\r\n?/gu, '\n').replace(/^---\n[\s\S]*?\n---(?:\n|$)/u, '');
21
24
  }
22
25
  export function resolveArticleImage(sourcePath, reference) {
23
- if (!sourcePath || /[\\:]|^\//u.test(sourcePath) || /[\\:]|^\//u.test(reference)) throw new Error('Invalid workspace image path');
24
- const parts = sourcePath.split('/').slice(0, -1);
26
+ if (typeof sourcePath !== 'string' || typeof reference !== 'string'
27
+ || !sourcePath || !reference || /[\\:\u0000-\u001f]|^\//u.test(sourcePath)
28
+ || /[\\:\u0000-\u001f]|^\//u.test(reference)) throw new Error('Invalid workspace image path');
29
+ const parts = [];
30
+ for (const segment of sourcePath.split('/')) {
31
+ if (!segment || segment === '.') continue;
32
+ if (segment === '..') { if (!parts.length) throw new Error('Source escapes workspace'); parts.pop(); }
33
+ else parts.push(segment);
34
+ }
35
+ if (!parts.length) throw new Error('Invalid workspace image path');
36
+ parts.pop();
25
37
  for (const segment of reference.split('/')) {
26
38
  if (!segment || segment === '.') continue;
27
39
  if (segment === '..') { if (!parts.length) throw new Error('Image escapes workspace'); parts.pop(); }
@@ -29,14 +41,19 @@ export function resolveArticleImage(sourcePath, reference) {
29
41
  }
30
42
  return parts.join('/');
31
43
  }
32
- export async function prepareArticle(input, readImage) {
44
+ async function collectArticleImages(input, readImage) {
33
45
  const images = Object.create(null);
34
46
  for (const match of String(input.markdown).matchAll(/!\[[^\]]*\]\(([^\s)]+)(?:\s+"[^"]*")?\)/gu)) {
35
47
  const source = match[1];
36
48
  if (/^(https?:|data:)/iu.test(source) || Object.hasOwn(images, source)) continue;
37
- try { images[source] = await readImage(resolveArticleImage(input.sourcePath, source)); }
49
+ const relativePath = resolveArticleImage(input.sourcePath, source);
50
+ try { images[source] = await readImage(relativePath); }
38
51
  catch { images[source] = ''; }
39
52
  }
53
+ return images;
54
+ }
55
+ export async function prepareArticle(input, readImage) {
56
+ const images = await collectArticleImages(input, readImage);
40
57
  return renderArticle({ ...input, images });
41
58
  }
42
59
  function safeUrl(value, image = false) {
@@ -128,3 +145,207 @@ export function renderArticle({ markdown = '', platform = 'wechat', theme = 'min
128
145
  const checklist = ['# 图片交付清单', '', '按正文出现顺序核对;内嵌不代表目标平台已托管或发布。', '', ...imageList.map((item, i) => `${i + 1}. ${item.status} · ${item.source.replace(/[\r\n]/gu, ' ')}`)].join('\n');
129
146
  return { platform, theme, html, body, text, images: imageList, diagnostics, checklist };
130
147
  }
148
+
149
+ const ARTICLE_DIAGNOSTIC_MESSAGES = Object.freeze({
150
+ 'image-unavailable': 'Image could not be loaded or embedded.',
151
+ 'unsafe-link': 'Unsafe link was removed from the rendered output.',
152
+ 'unclosed-code-fence': 'Code fence was not closed.',
153
+ 'diagram-source-only': 'Diagram source needs a separate visual render.',
154
+ 'table-paste-check': 'Check table layout after pasting into the target editor.',
155
+ 'container-as-text': 'Unsupported container was rendered as text.',
156
+ 'remote-image-unverified': 'Remote image was referenced but its availability was not verified.',
157
+ });
158
+ const MAX_BOUND_SOURCE_BYTES = 16 * 1024 * 1024;
159
+ const MAX_BOUND_IMAGE_BASE64_CHARS = 48 * 1024 * 1024;
160
+ function validBoundSourcePath(value) {
161
+ return typeof value === 'string' && value.length > 0 && value.length <= 2048
162
+ && /\.(?:md|markdown)$/iu.test(value) && !/[\\:\u0000-\u001f]/u.test(value)
163
+ && value.split('/').every(segment => segment && segment !== '.' && segment !== '..');
164
+ }
165
+ async function embeddedImageBytesSha256(value) {
166
+ if (typeof value !== 'string') return null;
167
+ const match = value.match(/^data:image\/(?:png|jpe?g|gif|webp);base64,([a-z0-9+/]+={0,2})$/iu);
168
+ if (!match || match[1].length > MAX_BOUND_IMAGE_BASE64_CHARS || typeof atob !== 'function') return null;
169
+ try {
170
+ const binary = atob(match[1]);
171
+ return await sha256Bytes(Uint8Array.from(binary, char => char.charCodeAt(0)));
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+ function articleInputSnapshot(input, delivery) {
177
+ const images = Object.entries(input.images ?? {})
178
+ .map(([source, value]) => [source, String(value)])
179
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
180
+ return {
181
+ markdown: String(input.markdown ?? ''),
182
+ platform: delivery.platform,
183
+ theme: delivery.theme,
184
+ title: String(input.title ?? ''),
185
+ sourcePath: input.sourcePath == null ? null : String(input.sourcePath),
186
+ images,
187
+ provider: ARTICLE_RENDERER_PROVIDER,
188
+ };
189
+ }
190
+ function articleFailure(error) {
191
+ const message = error instanceof Error ? error.message : '';
192
+ const known = {
193
+ 'Unsupported delivery format': ['unsupported-platform', 'Unsupported delivery format.'],
194
+ 'Unknown writing theme': ['unknown-theme', 'Unknown writing theme.'],
195
+ 'Invalid workspace image path': ['invalid-image-path', 'Image path must remain inside the workspace.'],
196
+ 'Source escapes workspace': ['invalid-image-path', 'Image path must remain inside the workspace.'],
197
+ 'Image escapes workspace': ['invalid-image-path', 'Image path must remain inside the workspace.'],
198
+ 'Invalid workspace source path': ['invalid-source-path', 'Source path must be a canonical workspace-relative Markdown path.'],
199
+ 'Invalid source bytes': ['invalid-source-bytes', 'Source must be supplied as UTF-8 bytes.'],
200
+ 'Invalid source encoding': ['invalid-source-encoding', 'Source bytes must be valid UTF-8.'],
201
+ 'Source too large': ['source-too-large', 'Source exceeds the 16 MiB render limit.'],
202
+ 'Invalid creation snapshot fingerprint': ['invalid-snapshot-fingerprint', 'Creation snapshot fingerprint must be SHA-256.'],
203
+ 'Invalid article title': ['invalid-title', 'Article title must be a string.'],
204
+ 'Unsupported bound renderer option': ['unsupported-render-option', 'Byte-bound rendering accepts only platform, theme and title options.'],
205
+ }[message];
206
+ return {
207
+ schemaVersion: 1,
208
+ capabilityId: 'article.render',
209
+ provider: ARTICLE_RENDERER_PROVIDER,
210
+ status: 'error',
211
+ input: { fingerprint: null },
212
+ artifacts: [],
213
+ images: [],
214
+ diagnostics: [{ code: known?.[0] ?? 'render-failed', severity: 'error', message: known?.[1] ?? 'Article could not be rendered.' }],
215
+ };
216
+ }
217
+
218
+ // Extract a local text fallback only from markup produced by renderArticle.
219
+ // It is an estimate for preflight checks, never a target editor readback.
220
+ function localClipboardText(html) {
221
+ return html
222
+ .replace(/<img\b[^>]*\/?\s*>/giu, '')
223
+ .replace(/<br\s*\/?\s*>/giu, '\n')
224
+ .replace(/<\/(?:p|h[1-6]|blockquote|li|tr|section|table)>/giu, '\n')
225
+ .replace(/<\/(?:th|td)>/giu, '\t')
226
+ .replace(/<hr\b[^>]*\/?\s*>/giu, '\n')
227
+ .replace(/<[^>]+>/gu, '')
228
+ .replace(/&lt;/gu, '<').replace(/&gt;/gu, '>')
229
+ .replace(/&quot;/gu, '"').replace(/&#39;/gu, "'")
230
+ .replace(/&amp;/gu, '&')
231
+ .replace(/[\t ]+\n/gu, '\n')
232
+ .replace(/\n{3,}/gu, '\n\n')
233
+ .trim();
234
+ }
235
+
236
+ /** Prepared local text for a length preflight; the target editor must be checked separately. */
237
+ export function articleLocalTextDocument(result) {
238
+ if (result?.status === 'error' || !result?.input?.source) return null;
239
+ const artifact = result.artifacts?.find(item => item.role === 'local-clipboard-text');
240
+ if (artifact?.format !== 'text/plain' || typeof artifact.content !== 'string' ||
241
+ !/^[a-f0-9]{64}$/u.test(artifact.sha256 ?? '')) return null;
242
+ const title = result.input.localTextTitle;
243
+ if (title !== null && typeof title !== 'string') return null;
244
+ return { text_kind: 'platform_text', ...(title === null ? {} : { title }), body: artifact.content };
245
+ }
246
+ /** Deterministic, machine-readable delivery. The legacy renderArticle shape is unchanged. */
247
+ export async function renderArticleResult(input = {}) {
248
+ try {
249
+ const delivery = renderArticle(input);
250
+ const fingerprint = await sha256(JSON.stringify(articleInputSnapshot(input, delivery)));
251
+ const artifacts = await Promise.all([
252
+ { role: 'preview', format: 'text/html', content: delivery.html },
253
+ { role: 'clipboard-html', format: 'text/html', content: delivery.body },
254
+ { role: 'source-markdown', format: 'text/markdown', content: delivery.text },
255
+ { role: 'image-checklist', format: 'text/markdown', content: delivery.checklist },
256
+ ].map(async artifact => ({ ...artifact, sha256: await sha256(artifact.content) })));
257
+ const diagnostics = [
258
+ ...delivery.diagnostics.map(diagnostic => ({
259
+ code: diagnostic.code,
260
+ severity: 'warning',
261
+ message: ARTICLE_DIAGNOSTIC_MESSAGES[diagnostic.code] ?? 'Review the rendered output.',
262
+ ...(diagnostic.source == null ? {} : { location: { source: diagnostic.source } }),
263
+ })),
264
+ ...delivery.images.filter(image => image.status === 'remote').map(image => ({
265
+ code: 'remote-image-unverified',
266
+ severity: 'warning',
267
+ message: ARTICLE_DIAGNOSTIC_MESSAGES['remote-image-unverified'],
268
+ location: { source: image.source },
269
+ })),
270
+ ];
271
+ return {
272
+ schemaVersion: 1,
273
+ capabilityId: 'article.render',
274
+ provider: ARTICLE_RENDERER_PROVIDER,
275
+ status: diagnostics.length ? 'warning' : 'ok',
276
+ input: { fingerprint, target: { platform: delivery.platform, theme: delivery.theme } },
277
+ artifacts,
278
+ images: delivery.images,
279
+ diagnostics,
280
+ };
281
+ } catch (error) {
282
+ return articleFailure(error);
283
+ }
284
+ }
285
+ /** Load local images through the host, then fingerprint their actual returned data. */
286
+ export async function prepareArticleResult(input, readImage) {
287
+ try {
288
+ const images = await collectArticleImages(input, readImage);
289
+ return await renderArticleResult({ ...input, images });
290
+ } catch (error) {
291
+ return articleFailure(error);
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Render exact UTF-8 source bytes supplied by a trusted host. The host compares
297
+ * input.source against the selected Kernel snapshot before using the artifacts.
298
+ */
299
+ export async function prepareArticleResultFromSource({
300
+ sourcePath, sourceBytes, creationSnapshotFingerprint, ...options
301
+ } = {}, readImage) {
302
+ if (!validBoundSourcePath(sourcePath)) return articleFailure(new Error('Invalid workspace source path'));
303
+ if (!(sourceBytes instanceof Uint8Array)) return articleFailure(new Error('Invalid source bytes'));
304
+ if (sourceBytes.byteLength > MAX_BOUND_SOURCE_BYTES) return articleFailure(new Error('Source too large'));
305
+ if (creationSnapshotFingerprint !== undefined &&
306
+ (typeof creationSnapshotFingerprint !== 'string' ||
307
+ !/^[a-f0-9]{64}$/u.test(creationSnapshotFingerprint))) {
308
+ return articleFailure(new Error('Invalid creation snapshot fingerprint'));
309
+ }
310
+ if (options.title !== undefined && typeof options.title !== 'string') {
311
+ return articleFailure(new Error('Invalid article title'));
312
+ }
313
+ if (Object.keys(options).some(key => !['platform', 'theme', 'title'].includes(key))) {
314
+ return articleFailure(new Error('Unsupported bound renderer option'));
315
+ }
316
+ // Own the bytes before the first await so caller mutation cannot split the
317
+ // hash from the Markdown actually rendered.
318
+ const stableBytes = Uint8Array.from(sourceBytes);
319
+ let markdown;
320
+ try {
321
+ markdown = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(stableBytes);
322
+ } catch {
323
+ return articleFailure(new Error('Invalid source encoding'));
324
+ }
325
+ const source = { relativePath: sourcePath, sha256: await sha256Bytes(stableBytes) };
326
+ const readImages = new Map();
327
+ const trackedReadImage = async relativePath => {
328
+ const dataUrl = await readImage(relativePath);
329
+ const sha256 = await embeddedImageBytesSha256(dataUrl);
330
+ if (sha256) readImages.set(relativePath, sha256);
331
+ return dataUrl;
332
+ };
333
+ const result = await prepareArticleResult({ ...options, sourcePath, markdown }, trackedReadImage);
334
+ if (result.status === 'error') return result;
335
+ const clipboard = result.artifacts.find(item => item.role === 'clipboard-html');
336
+ const localTextArtifact = { role: 'local-clipboard-text', format: 'text/plain',
337
+ content: localClipboardText(clipboard.content) };
338
+ localTextArtifact.sha256 = await sha256(localTextArtifact.content);
339
+ const localTextTitle = options.title ?? null;
340
+ const renderParameters = { theme: result.input.target.theme, title: options.title ?? '' };
341
+ const imageBytes = [...readImages].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
342
+ .map(([relativePath, sha256]) => ({ relativePath, sha256 }));
343
+ return { ...result, artifacts: [...result.artifacts, localTextArtifact], input: { ...result.input, source,
344
+ imageBytes,
345
+ localTextTitle,
346
+ renderParameters,
347
+ ...(creationSnapshotFingerprint === undefined ? {} : { creationSnapshotFingerprint }),
348
+ fingerprint: await sha256(JSON.stringify({ rendererFingerprint: result.input.fingerprint, source, imageBytes,
349
+ localTextTitle, renderParameters, localTextSha256: localTextArtifact.sha256,
350
+ ...(creationSnapshotFingerprint === undefined ? {} : { creationSnapshotFingerprint }) })) } };
351
+ }
@@ -0,0 +1,19 @@
1
+ /** SHA-256 for browser and Node hosts; never hashes by reading a path. */
2
+ export async function sha256Bytes(bytes) {
3
+ if (!(bytes instanceof Uint8Array)) throw new TypeError('SHA-256 input must be bytes');
4
+ if (globalThis.crypto?.subtle) {
5
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
6
+ return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
7
+ }
8
+ // Node 18 may run without the global Web Crypto API. Keep browser imports portable.
9
+ if (typeof process !== 'undefined' && process.versions?.node) {
10
+ const nodeCrypto = 'node:crypto';
11
+ const { createHash } = await import(nodeCrypto);
12
+ return createHash('sha256').update(bytes).digest('hex');
13
+ }
14
+ throw new Error('SHA-256 is unavailable');
15
+ }
16
+
17
+ export async function sha256(value) {
18
+ return sha256Bytes(new TextEncoder().encode(value));
19
+ }
@@ -0,0 +1,24 @@
1
+ export type LengthTarget = { platform: string; format: string; entrypoint: string; account_capability: string };
2
+ export type LengthRule = {
3
+ id: string;
4
+ category: 'platform_limit' | 'creator_preference';
5
+ status: string;
6
+ applies_to: LengthTarget;
7
+ check: { field: 'title' | 'body'; operation: 'max_characters'; counter: 'unicode_code_points'; max: number };
8
+ outcome: { exceeded: 'block' | 'warn' };
9
+ evidence: { tier: string; url?: string; checked_at?: string; review_after?: string; confirmed_at?: string };
10
+ };
11
+ export interface LengthRuleResult {
12
+ schemaVersion: 1;
13
+ capabilityId: 'platform.validate.length';
14
+ provider: Readonly<{ id: string; version: string }>;
15
+ status: 'ok' | 'warning' | 'error';
16
+ verdict: 'pass' | 'review' | 'block' | 'invalid';
17
+ input: { fingerprint: string | null; rulesDigest?: string; documentDigest?: string; target?: LengthTarget; asOf?: string };
18
+ artifacts: [];
19
+ checks: Array<{ ruleId: string; category: string; field: 'title' | 'body'; observed: number; max: number; unit: 'unicode_code_points'; exceeded: boolean }>;
20
+ effectiveLimits: Array<{ field: 'title' | 'body'; unit: 'unicode_code_points'; hardMax: number | null; preferenceMax: number | null; effectiveMax: number }>;
21
+ diagnostics: Array<{ code: string; severity: 'warning' | 'error'; message: string; ruleId?: string; dimension?: string; location?: { field: string }; observed?: number; max?: number; unit?: string; hardMax?: number; preferenceMax?: number }>;
22
+ }
23
+ export const LENGTH_RULE_PROVIDER: Readonly<{ id: string; version: string }>;
24
+ export function evaluateLengthRules(input: { target: LengthTarget; document: { text_kind: 'platform_text'; title?: string; body?: string }; rules: LengthRule[]; asOf?: string }): Promise<LengthRuleResult>;
@@ -0,0 +1,262 @@
1
+ /** Narrow, deterministic Unicode code-point length evaluator. Rules are caller supplied. */
2
+ import { sha256 } from './content-sha256.mjs';
3
+
4
+ export const LENGTH_RULE_PROVIDER = Object.freeze({ id: 'topmind-writing-skills/length-rules', version: '2.1.0' });
5
+ const SCOPE_FIELDS = Object.freeze(['platform', 'format', 'entrypoint', 'account_capability']);
6
+ const CONTENT_FIELDS = new Set(['title', 'body']);
7
+ const SENSITIVE_KEY = /(?:token|secret|password|credential|authorization|cookie|api[_-]?key)/iu;
8
+ const UNSAFE_KEY = new Set(['__proto__', 'prototype', 'constructor']);
9
+
10
+ function diagnostic(code, severity, message, extras = {}) {
11
+ return { code, severity, message, ...extras };
12
+ }
13
+ function compareId(left, right) {
14
+ const a = String(left?.id ?? '');
15
+ const b = String(right?.id ?? '');
16
+ return a < b ? -1 : a > b ? 1 : 0;
17
+ }
18
+ function canonical(value, seen = new WeakSet()) {
19
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
20
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
21
+ if (!value || typeof value !== 'object' || seen.has(value)) throw new TypeError('Rules must be finite JSON data');
22
+ seen.add(value);
23
+ try {
24
+ if (Array.isArray(value)) return value.map(item => canonical(item, seen));
25
+ const proto = Object.getPrototypeOf(value);
26
+ if (proto !== Object.prototype && proto !== null) throw new TypeError('Rules must be plain JSON data');
27
+ const ordered = Object.create(null);
28
+ for (const key of Object.keys(value).sort()) {
29
+ if (UNSAFE_KEY.has(key) || SENSITIVE_KEY.test(key)) throw new TypeError('Rules contain a forbidden field');
30
+ ordered[key] = canonical(value[key], seen);
31
+ }
32
+ return ordered;
33
+ } finally {
34
+ seen.delete(value);
35
+ }
36
+ }
37
+ function hasScope(scope) {
38
+ return scope && typeof scope === 'object' && !Array.isArray(scope)
39
+ && Object.keys(scope).every(key => SCOPE_FIELDS.includes(key))
40
+ && SCOPE_FIELDS.every(key => typeof scope[key] === 'string' && scope[key].trim());
41
+ }
42
+ function scopeMatch(scope, target) {
43
+ let unknown = null;
44
+ for (const key of SCOPE_FIELDS) {
45
+ if (scope[key] === 'any') continue;
46
+ if (target[key] === 'unknown') { unknown ??= key; continue; }
47
+ if (scope[key] !== target[key]) return { matches: false };
48
+ }
49
+ return unknown ? { matches: false, unknown } : { matches: true };
50
+ }
51
+ function validDate(value) {
52
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) return false;
53
+ const parsed = new Date(`${value}T00:00:00Z`);
54
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
55
+ }
56
+ function isOfficialEvidence(evidence) {
57
+ if (evidence?.tier !== 'official_primary' || !validDate(evidence.checked_at)) return false;
58
+ try { return new URL(evidence.url).protocol === 'https:'; }
59
+ catch { return false; }
60
+ }
61
+ function baseResult() {
62
+ return {
63
+ schemaVersion: 1,
64
+ capabilityId: 'platform.validate.length',
65
+ provider: LENGTH_RULE_PROVIDER,
66
+ status: 'error',
67
+ verdict: 'invalid',
68
+ input: { fingerprint: null },
69
+ artifacts: [],
70
+ checks: [],
71
+ effectiveLimits: [],
72
+ diagnostics: [],
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Evaluate only max_characters + unicode_code_points. This is not X weighted length.
78
+ * A rule must name all four scope dimensions, even when a dimension is `any`.
79
+ */
80
+ export async function evaluateLengthRules({ target, document, rules, asOf } = {}) {
81
+ const result = baseResult();
82
+ if (asOf !== undefined && !validDate(asOf)) {
83
+ result.diagnostics.push(diagnostic('invalid-evaluation-date', 'error', 'Evaluation date must be an ISO calendar date.'));
84
+ return result;
85
+ }
86
+ if (!hasScope(target) || Object.values(target).some(value => value === 'any')) {
87
+ result.diagnostics.push(diagnostic('invalid-target-scope', 'error', 'Target must name platform, format, entrypoint and account capability; use unknown when unresolved.'));
88
+ return result;
89
+ }
90
+ if (!document || typeof document !== 'object' || Array.isArray(document) || !Array.isArray(rules)) {
91
+ result.diagnostics.push(diagnostic('invalid-rule-input', 'error', 'Document must be an object and rules must be an array.'));
92
+ return result;
93
+ }
94
+ if (document.text_kind !== 'platform_text') {
95
+ result.diagnostics.push(diagnostic('invalid-content-kind', 'error', 'Length checks require prepared platform text, not raw Markdown.'));
96
+ return result;
97
+ }
98
+ const sortedRules = [...rules].sort(compareId);
99
+ try {
100
+ const preparedDocument = { text_kind: document.text_kind,
101
+ ...(Object.hasOwn(document, 'title') ? { title: document.title } : {}),
102
+ ...(Object.hasOwn(document, 'body') ? { body: document.body } : {}) };
103
+ // Keep the exact caller supplied rule array bound to the validation report.
104
+ // The Kernel snapshot computes the same canonical digest from ruleset.rules.
105
+ result.input.rulesDigest = await sha256(JSON.stringify(canonical(rules)));
106
+ result.input.documentDigest = await sha256(JSON.stringify(canonical(preparedDocument)));
107
+ result.input.fingerprint = await sha256(JSON.stringify(canonical({
108
+ target,
109
+ document: preparedDocument,
110
+ rules: sortedRules,
111
+ ...(asOf === undefined ? {} : { asOf }),
112
+ provider: LENGTH_RULE_PROVIDER,
113
+ })));
114
+ } catch {
115
+ result.input = { fingerprint: null };
116
+ result.diagnostics.push(diagnostic('input-fingerprint-failed', 'error', 'Input snapshot could not be fingerprinted.'));
117
+ return result;
118
+ }
119
+ result.input.target = {
120
+ platform: target.platform,
121
+ format: target.format,
122
+ entrypoint: target.entrypoint,
123
+ account_capability: target.account_capability,
124
+ };
125
+ if (asOf !== undefined) result.input.asOf = asOf;
126
+
127
+ let invalid = false;
128
+ let blocked = false;
129
+ let needsReview = false;
130
+ for (const dimension of SCOPE_FIELDS.filter(key => target[key] === 'unknown')) {
131
+ needsReview = true;
132
+ result.diagnostics.push(diagnostic('target-dimension-unknown', 'warning', 'Target dimension needs confirmation before validation can pass.', { dimension }));
133
+ }
134
+ const ids = new Set();
135
+ for (const rule of sortedRules) {
136
+ const ruleId = typeof rule?.id === 'string' && rule.id.trim() ? rule.id : null;
137
+ if (!ruleId || ids.has(ruleId)) {
138
+ invalid = true;
139
+ result.diagnostics.push(diagnostic('invalid-rule-id', 'error', 'Rule ID must be unique and nonempty.', { ...(ruleId ? { ruleId } : {}) }));
140
+ continue;
141
+ }
142
+ ids.add(ruleId);
143
+ if (!hasScope(rule.applies_to) || SCOPE_FIELDS.some(key => rule.applies_to[key] === 'unknown')) {
144
+ invalid = true;
145
+ result.diagnostics.push(diagnostic('invalid-rule-scope', 'error', 'Rule must specify exactly the four supported scope dimensions.', { ruleId }));
146
+ continue;
147
+ }
148
+ const match = scopeMatch(rule.applies_to, target);
149
+ if (!match.matches && !match.unknown) continue;
150
+ if (rule.status !== 'active') {
151
+ needsReview = true;
152
+ result.diagnostics.push(diagnostic('rule-inactive', 'warning', 'Rule is not active and was not applied.', { ruleId }));
153
+ continue;
154
+ }
155
+ if (match.unknown) {
156
+ needsReview = true;
157
+ result.diagnostics.push(diagnostic('target-scope-unknown', 'warning', 'Target capability or entrypoint needs confirmation before this rule can be applied.', { ruleId, dimension: match.unknown }));
158
+ continue;
159
+ }
160
+ if (!match.matches) continue;
161
+ const check = rule.check;
162
+ if (check?.operation !== 'max_characters' || check.counter !== 'unicode_code_points') {
163
+ needsReview = true;
164
+ result.diagnostics.push(diagnostic('unsupported-length-operation', 'warning', 'Length rule needs a supported operation and explicit counter.', { ruleId }));
165
+ continue;
166
+ }
167
+ if (!CONTENT_FIELDS.has(check.field) || !Number.isSafeInteger(check.max) || check.max < 0) {
168
+ invalid = true;
169
+ result.diagnostics.push(diagnostic('invalid-length-check', 'error', 'Length check needs title or body and a nonnegative integer maximum.', { ruleId }));
170
+ continue;
171
+ }
172
+ if (rule.category === 'platform_limit') {
173
+ if (rule.applies_to.platform === 'any' || rule.outcome?.exceeded !== 'block') {
174
+ invalid = true;
175
+ result.diagnostics.push(diagnostic('invalid-hard-rule', 'error', 'Platform hard limit needs a concrete platform and block outcome.', { ruleId }));
176
+ continue;
177
+ }
178
+ if (!isOfficialEvidence(rule.evidence)) {
179
+ needsReview = true;
180
+ result.diagnostics.push(diagnostic('hard-rule-evidence-unverified', 'warning', 'Official source and check date are required before treating a limit as blocking.', { ruleId }));
181
+ continue;
182
+ }
183
+ if (!validDate(rule.evidence.review_after) || rule.evidence.review_after < rule.evidence.checked_at) {
184
+ needsReview = true;
185
+ result.diagnostics.push(diagnostic('hard-rule-review-date-invalid', 'warning', 'Hard rule review date is invalid.', { ruleId }));
186
+ continue;
187
+ }
188
+ if (asOf === undefined) {
189
+ needsReview = true;
190
+ result.diagnostics.push(diagnostic('hard-rule-evaluation-date-required', 'warning', 'Hard rule needs an evaluation date to check its evidence.', { ruleId }));
191
+ continue;
192
+ }
193
+ if (asOf < rule.evidence.checked_at) {
194
+ needsReview = true;
195
+ result.diagnostics.push(diagnostic('hard-rule-evidence-not-yet-checked', 'warning', 'Hard rule evidence was checked after the evaluation date.', { ruleId }));
196
+ continue;
197
+ }
198
+ if (asOf > rule.evidence.review_after) {
199
+ needsReview = true;
200
+ result.diagnostics.push(diagnostic('hard-rule-evidence-expired', 'warning', 'Hard rule evidence needs review before use.', { ruleId }));
201
+ continue;
202
+ }
203
+ } else if (rule.category === 'creator_preference') {
204
+ if (rule.outcome?.exceeded !== 'warn' || rule.evidence?.tier !== 'user_confirmed') {
205
+ needsReview = true;
206
+ result.diagnostics.push(diagnostic('preference-unconfirmed', 'warning', 'Creator preference needs confirmation and a warn outcome before use.', { ruleId }));
207
+ continue;
208
+ }
209
+ if (!validDate(rule.evidence.confirmed_at) || asOf === undefined || asOf < rule.evidence.confirmed_at) {
210
+ needsReview = true;
211
+ result.diagnostics.push(diagnostic('preference-confirmation-date-unverified', 'warning', 'Creator preference confirmation date must precede evaluation.', { ruleId }));
212
+ continue;
213
+ }
214
+ } else {
215
+ needsReview = true;
216
+ result.diagnostics.push(diagnostic('unsupported-rule-category', 'warning', 'Rule category is outside this length evaluator.', { ruleId }));
217
+ continue;
218
+ }
219
+ if (typeof document[check.field] !== 'string') {
220
+ needsReview = true;
221
+ result.diagnostics.push(diagnostic('content-field-missing', 'warning', 'Target content field is unavailable.', { ruleId, location: { field: check.field } }));
222
+ continue;
223
+ }
224
+ const observed = Array.from(document[check.field]).length;
225
+ const exceeded = observed > check.max;
226
+ result.checks.push({ ruleId, category: rule.category, field: check.field, observed, max: check.max, unit: 'unicode_code_points', exceeded });
227
+ if (exceeded) {
228
+ const hard = rule.category === 'platform_limit';
229
+ blocked ||= hard;
230
+ needsReview ||= !hard;
231
+ result.diagnostics.push(diagnostic(hard ? 'hard-length-exceeded' : 'preference-length-exceeded', hard ? 'error' : 'warning', 'Content length exceeds the applicable maximum.', { ruleId, location: { field: check.field }, observed, max: check.max, unit: 'unicode_code_points' }));
232
+ }
233
+ }
234
+
235
+ for (const field of ['title', 'body']) {
236
+ const checks = result.checks.filter(check => check.field === field);
237
+ if (!checks.length) continue;
238
+ const hardMax = Math.min(...checks.filter(check => check.category === 'platform_limit').map(check => check.max));
239
+ const preferenceMax = Math.min(...checks.filter(check => check.category === 'creator_preference').map(check => check.max));
240
+ const hard = Number.isFinite(hardMax) ? hardMax : null;
241
+ const preference = Number.isFinite(preferenceMax) ? preferenceMax : null;
242
+ result.effectiveLimits.push({ field, unit: 'unicode_code_points', hardMax: hard, preferenceMax: preference, effectiveMax: Math.min(hard ?? Infinity, preference ?? Infinity) });
243
+ if (hard !== null) {
244
+ for (const check of checks.filter(check => check.category === 'creator_preference' && check.max > hard)) {
245
+ needsReview = true;
246
+ result.diagnostics.push(diagnostic('preference-widens-hard-limit', 'warning', 'Creator preference is wider than an applicable hard limit and cannot relax it.', { ruleId: check.ruleId, location: { field }, hardMax: hard, preferenceMax: check.max }));
247
+ }
248
+ }
249
+ }
250
+ if (!result.checks.length) {
251
+ needsReview = true;
252
+ result.diagnostics.push(diagnostic('no-applicable-length-rule', 'warning', 'No applicable length rule was evaluated for this target.'));
253
+ } else {
254
+ for (const limit of result.effectiveLimits.filter(item => item.preferenceMax !== null && item.hardMax === null)) {
255
+ needsReview = true;
256
+ result.diagnostics.push(diagnostic('no-applicable-platform-limit', 'warning', 'Creator preferences alone cannot confirm a platform length limit.', { location: { field: limit.field } }));
257
+ }
258
+ }
259
+ result.verdict = invalid ? 'invalid' : blocked ? 'block' : needsReview ? 'review' : 'pass';
260
+ result.status = result.verdict === 'pass' ? 'ok' : result.verdict === 'review' ? 'warning' : 'error';
261
+ return result;
262
+ }
@@ -0,0 +1,13 @@
1
+ import type { LengthRule, LengthRuleResult, LengthTarget } from './length-rules.mjs';
2
+
3
+ export type LengthRuleSet = { schemaVersion: 1; id: string; version: string; rules: LengthRule[] };
4
+ export type LengthRuleSetResult = Omit<LengthRuleResult, 'capabilityId' | 'input'> & {
5
+ capabilityId: 'platform.validate.length-ruleset';
6
+ input: LengthRuleResult['input'] & { ruleSet?: { id: string; version: string }; creationSnapshotFingerprint?: string };
7
+ };
8
+ export class LengthRuleSetError extends Error { code: string; }
9
+ export const LENGTH_RULESET_PROVIDER: Readonly<{ id: string; version: string }>;
10
+ export function parseLengthRuleSet(jsonText: string): LengthRuleSet;
11
+ export function evaluateLengthRuleSet(input: { ruleSetJson: string; target: LengthTarget;
12
+ document: { text_kind: 'platform_text'; title?: string; body?: string }; asOf: string;
13
+ creationSnapshotFingerprint?: string }): Promise<LengthRuleSetResult>;
@@ -0,0 +1,121 @@
1
+ /** Portable JSON rule-set loader and deterministic length report. No platform rules ship here. */
2
+ import { sha256 } from './content-sha256.mjs';
3
+ import { evaluateLengthRules } from './length-rules.mjs';
4
+
5
+ export const LENGTH_RULESET_PROVIDER = Object.freeze({ id: 'topmind-writing-skills/length-ruleset', version: '1.1.0' });
6
+ const MAX_JSON_BYTES = 1024 * 1024;
7
+ const MAX_RULES = 1000;
8
+ const SCOPE = ['platform', 'format', 'entrypoint', 'account_capability'];
9
+ const FORBIDDEN_KEY = /(?:token|secret|password|credential|authorization|cookie|api[_-]?key)/iu;
10
+
11
+ export class LengthRuleSetError extends Error {
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.name = 'LengthRuleSetError';
15
+ this.code = code;
16
+ }
17
+ }
18
+
19
+ function fail(code, message) { throw new LengthRuleSetError(code, message); }
20
+ function exactKeys(value, keys) {
21
+ return value && typeof value === 'object' && !Array.isArray(value)
22
+ && Object.keys(value).length === keys.length && Object.keys(value).every(key => keys.includes(key));
23
+ }
24
+ function validDate(value) {
25
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/u.test(value)) return false;
26
+ const parsed = new Date(`${value}T00:00:00Z`);
27
+ return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
28
+ }
29
+ function safeJson(value, depth = 0, budget = { nodes: 0 }) {
30
+ if (++budget.nodes > 100_000 || depth > 32) fail('ruleset-too-complex', 'Rule set exceeds the supported JSON complexity.');
31
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
32
+ if (typeof value === 'number' && Number.isFinite(value)) return;
33
+ if (!value || typeof value !== 'object') fail('invalid-ruleset-json', 'Rule set must contain finite JSON data.');
34
+ for (const key of Object.keys(value)) {
35
+ if (['__proto__', 'prototype', 'constructor'].includes(key) || FORBIDDEN_KEY.test(key)) {
36
+ fail('forbidden-ruleset-field', 'Rule set contains a forbidden field.');
37
+ }
38
+ safeJson(value[key], depth + 1, budget);
39
+ }
40
+ }
41
+ function label(value) {
42
+ return typeof value === 'string' && /^[a-z0-9][a-z0-9._-]{0,127}$/u.test(value);
43
+ }
44
+ function ruleShape(rule) {
45
+ if (!exactKeys(rule, ['id', 'category', 'status', 'applies_to', 'check', 'outcome', 'evidence']) || !label(rule.id)) return false;
46
+ if (!['active', 'proposed_unconfirmed'].includes(rule.status)) return false;
47
+ if (!exactKeys(rule.applies_to, SCOPE) || SCOPE.some(key => typeof rule.applies_to[key] !== 'string'
48
+ || !rule.applies_to[key].trim() || rule.applies_to[key] === 'unknown')) return false;
49
+ if (!exactKeys(rule.check, ['field', 'operation', 'counter', 'max'])
50
+ || !['title', 'body'].includes(rule.check.field) || rule.check.operation !== 'max_characters'
51
+ || rule.check.counter !== 'unicode_code_points' || !Number.isSafeInteger(rule.check.max) || rule.check.max < 0) return false;
52
+ if (!exactKeys(rule.outcome, ['exceeded'])) return false;
53
+ if (rule.category === 'platform_limit') {
54
+ if (rule.applies_to.platform === 'any' || rule.outcome.exceeded !== 'block'
55
+ || !exactKeys(rule.evidence, ['tier', 'url', 'checked_at', 'review_after'])
56
+ || rule.evidence.tier !== 'official_primary' || !validDate(rule.evidence.checked_at)
57
+ || !validDate(rule.evidence.review_after) || rule.evidence.review_after < rule.evidence.checked_at) return false;
58
+ try {
59
+ const url = new URL(rule.evidence.url);
60
+ return url.protocol === 'https:' && !url.username && !url.password;
61
+ } catch { return false; }
62
+ }
63
+ if (rule.category !== 'creator_preference' || rule.outcome.exceeded !== 'warn') return false;
64
+ if (rule.status === 'active') {
65
+ return exactKeys(rule.evidence, ['tier', 'confirmed_at'])
66
+ && rule.evidence.tier === 'user_confirmed' && validDate(rule.evidence.confirmed_at);
67
+ }
68
+ return exactKeys(rule.evidence, ['tier']) && rule.evidence.tier === 'user_preference_pending_confirmation';
69
+ }
70
+
71
+ /** Parse a reviewed JSON document. The caller must verify source claims before activation. */
72
+ export function parseLengthRuleSet(jsonText) {
73
+ if (typeof jsonText !== 'string' || new TextEncoder().encode(jsonText).length > MAX_JSON_BYTES) {
74
+ fail('invalid-ruleset-json', 'Rule set must be JSON text no larger than 1 MiB.');
75
+ }
76
+ let value;
77
+ try { value = JSON.parse(jsonText); }
78
+ catch { fail('invalid-ruleset-json', 'Rule set is not valid JSON.'); }
79
+ safeJson(value);
80
+ if (!exactKeys(value, ['schemaVersion', 'id', 'version', 'rules']) || value.schemaVersion !== 1
81
+ || !label(value.id) || !label(value.version) || !Array.isArray(value.rules) || value.rules.length > MAX_RULES) {
82
+ fail('invalid-ruleset', 'Rule set needs schemaVersion 1, id, version and a bounded rules array.');
83
+ }
84
+ const ids = new Set();
85
+ for (const rule of value.rules) {
86
+ if (!ruleShape(rule)) fail('invalid-ruleset-rule', 'Rule set contains an unsupported or incomplete length rule.');
87
+ if (ids.has(rule.id)) fail('duplicate-ruleset-rule', 'Rule IDs must be unique within a rule set.');
88
+ ids.add(rule.id);
89
+ }
90
+ return value;
91
+ }
92
+
93
+ function invalidResult(code, message) {
94
+ return { schemaVersion: 1, capabilityId: 'platform.validate.length-ruleset', provider: LENGTH_RULESET_PROVIDER,
95
+ status: 'error', verdict: 'invalid', input: { fingerprint: null }, artifacts: [], checks: [],
96
+ effectiveLimits: [], diagnostics: [{ code, severity: 'error', message }] };
97
+ }
98
+
99
+ /** Evaluate one explicit target and date; the result's rulesDigest matches Kernel ruleset.rules. */
100
+ export async function evaluateLengthRuleSet({ ruleSetJson, target, document, asOf, creationSnapshotFingerprint } = {}) {
101
+ if (!validDate(asOf)) return invalidResult('invalid-evaluation-date', 'Evaluation date must be an ISO calendar date.');
102
+ if (creationSnapshotFingerprint !== undefined &&
103
+ (typeof creationSnapshotFingerprint !== 'string' || !/^[a-f0-9]{64}$/u.test(creationSnapshotFingerprint))) {
104
+ return invalidResult('invalid-snapshot-fingerprint', 'Creation snapshot fingerprint must be SHA-256.');
105
+ }
106
+ let ruleSet;
107
+ try { ruleSet = parseLengthRuleSet(ruleSetJson); }
108
+ catch (error) {
109
+ if (error instanceof LengthRuleSetError) return invalidResult(error.code, error.message);
110
+ throw error;
111
+ }
112
+ const evaluated = await evaluateLengthRules({ target, document, rules: ruleSet.rules, asOf });
113
+ const input = { ...evaluated.input, ruleSet: { id: ruleSet.id, version: ruleSet.version },
114
+ ...(creationSnapshotFingerprint === undefined ? {} : { creationSnapshotFingerprint }) };
115
+ if (input.fingerprint) input.fingerprint = await sha256(JSON.stringify({
116
+ evaluationFingerprint: input.fingerprint, rulesDigest: input.rulesDigest,
117
+ ruleSet: input.ruleSet, provider: LENGTH_RULESET_PROVIDER,
118
+ ...(creationSnapshotFingerprint === undefined ? {} : { creationSnapshotFingerprint }),
119
+ }));
120
+ return { ...evaluated, capabilityId: 'platform.validate.length-ruleset', provider: LENGTH_RULESET_PROVIDER, input };
121
+ }