@avocadostudio-ai/orchestrator-core 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/anthropic-planner.d.ts +8 -0
- package/dist/chat/anthropic-planner.js +166 -12
- package/dist/chat/chat-pipeline-translation.d.ts +13 -0
- package/dist/chat/chat-pipeline-translation.js +109 -45
- package/dist/chat/chat-pipeline.d.ts +1 -1
- package/dist/chat/chat-pipeline.js +296 -53
- package/dist/chat/gemini-planner.d.ts +2 -0
- package/dist/chat/gemini-planner.js +2 -1
- package/dist/chat/planner-types.d.ts +15 -0
- package/dist/chat/planner-types.js +2 -2
- package/dist/chat/planner.d.ts +12 -0
- package/dist/chat/planner.js +16 -2
- package/dist/chat/translation-chunking.d.ts +124 -0
- package/dist/chat/translation-chunking.js +371 -0
- package/dist/checks/field-walk.d.ts +25 -0
- package/dist/checks/field-walk.js +152 -0
- package/dist/checks/index.d.ts +5 -0
- package/dist/checks/index.js +4 -0
- package/dist/checks/page-weight.d.ts +22 -0
- package/dist/checks/page-weight.js +200 -0
- package/dist/checks/rules-draft.d.ts +2 -0
- package/dist/checks/rules-draft.js +375 -0
- package/dist/checks/run-checks.d.ts +32 -0
- package/dist/checks/run-checks.js +152 -0
- package/dist/checks/session-runner.d.ts +19 -0
- package/dist/checks/session-runner.js +95 -0
- package/dist/checks/types.d.ts +65 -0
- package/dist/checks/types.js +1 -0
- package/dist/durable/durable-store-singleton.d.ts +37 -0
- package/dist/durable/durable-store-singleton.js +179 -0
- package/dist/durable/finding-impact.d.ts +30 -0
- package/dist/durable/finding-impact.js +53 -0
- package/dist/durable/in-memory-durable-store.d.ts +203 -0
- package/dist/durable/in-memory-durable-store.js +363 -0
- package/dist/durable/index.d.ts +5 -0
- package/dist/durable/index.js +4 -0
- package/dist/durable/pending-plan-store.d.ts +28 -0
- package/dist/durable/pending-plan-store.js +156 -0
- package/dist/durable/sqlite-durable-store.d.ts +71 -0
- package/dist/durable/sqlite-durable-store.js +631 -0
- package/dist/durable/types.d.ts +265 -0
- package/dist/durable/types.js +1 -0
- package/dist/handler/create-orchestrator.d.ts +4 -0
- package/dist/handler/create-orchestrator.js +67 -4
- package/dist/http/audio-actions.d.ts +1 -1
- package/dist/http/checks-actions.d.ts +39 -0
- package/dist/http/checks-actions.js +122 -0
- package/dist/http/history-actions.d.ts +1 -1
- package/dist/http/image-generate-actions.d.ts +2 -2
- package/dist/http/ops-actions.d.ts +2 -2
- package/dist/http/publish-actions.d.ts +4 -4
- package/dist/http/restore-actions.d.ts +3 -3
- package/dist/http/screenshot-actions.d.ts +2 -2
- package/dist/http/session-actions.d.ts +1 -1
- package/dist/http/telemetry-feedback-actions.d.ts +2 -2
- package/dist/http/unsplash-actions.d.ts +2 -2
- package/dist/http/variations-actions.d.ts +2 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.js +27 -0
- package/dist/nlp/deterministic-planner-context.d.ts +16 -0
- package/dist/nlp/deterministic-planner-context.js +33 -7
- package/dist/nlp/plan-normalizer.js +54 -6
- package/dist/ops/destructive-action-gate.js +7 -2
- package/dist/ops/ops-engine.d.ts +12 -1
- package/dist/ops/ops-engine.js +41 -14
- package/dist/publish/publish-target-registry.js +1 -1
- package/dist/publish/publish-target.d.ts +1 -1
- package/dist/state/session-state.js +8 -1
- package/package.json +3 -3
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { IMAGE_PLACEHOLDER, isKnownRoute, parseLink, toAltPath } from "@avocadostudio-ai/shared";
|
|
2
|
+
import { fieldText, groupByBlock } from "./field-walk.js";
|
|
3
|
+
/*
|
|
4
|
+
* The eleven-ish draft-tier rules. Each is a pure function; none does IO.
|
|
5
|
+
*
|
|
6
|
+
* Severity discipline, because findings fatigue is the failure mode and a panel
|
|
7
|
+
* with 300 warnings is a panel nobody opens:
|
|
8
|
+
* error — this is broken and a crawler or a screen reader sees it
|
|
9
|
+
* warning — this is very likely wrong
|
|
10
|
+
* info — worth a look, and safe to ignore forever
|
|
11
|
+
*/
|
|
12
|
+
const TITLE_MIN = 20;
|
|
13
|
+
const TITLE_MAX = 60;
|
|
14
|
+
const DESCRIPTION_MIN = 70;
|
|
15
|
+
const DESCRIPTION_MAX = 160;
|
|
16
|
+
const THIN_CONTENT_CHARS = 120;
|
|
17
|
+
function effectiveTitle(page) {
|
|
18
|
+
return (page.meta?.title ?? page.title ?? "").trim();
|
|
19
|
+
}
|
|
20
|
+
function nonEmpty(value) {
|
|
21
|
+
return fieldText(value).trim().length > 0;
|
|
22
|
+
}
|
|
23
|
+
function textFields(ctx) {
|
|
24
|
+
return ctx.fields.filter((f) => f.kind === "text" || f.kind === "richtext");
|
|
25
|
+
}
|
|
26
|
+
function evidenceFor(field, excerpt) {
|
|
27
|
+
return {
|
|
28
|
+
source: "draft",
|
|
29
|
+
blockId: field.blockId,
|
|
30
|
+
blockType: field.blockType,
|
|
31
|
+
...(field.blockLabel ? { blockLabel: field.blockLabel } : {}),
|
|
32
|
+
path: field.path,
|
|
33
|
+
...(excerpt ? { excerpt } : {})
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Page metadata
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
const titleMissing = {
|
|
40
|
+
id: "seo.title-missing",
|
|
41
|
+
agent: "seo",
|
|
42
|
+
severity: "error",
|
|
43
|
+
run: (ctx) => effectiveTitle(ctx.page)
|
|
44
|
+
? []
|
|
45
|
+
: [
|
|
46
|
+
{
|
|
47
|
+
title: "Page has no title",
|
|
48
|
+
detail: "Neither meta.title nor the page title is set, so the tab and the search result have nothing to show."
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
};
|
|
52
|
+
const titleLength = {
|
|
53
|
+
id: "seo.title-length",
|
|
54
|
+
agent: "seo",
|
|
55
|
+
severity: "info",
|
|
56
|
+
run: (ctx) => {
|
|
57
|
+
const title = effectiveTitle(ctx.page);
|
|
58
|
+
if (!title)
|
|
59
|
+
return []; // titleMissing owns that case; two findings for one fact is noise
|
|
60
|
+
if (title.length < TITLE_MIN) {
|
|
61
|
+
return [{ title: `Title is ${title.length} characters (aim for ${TITLE_MIN}–${TITLE_MAX})` }];
|
|
62
|
+
}
|
|
63
|
+
if (title.length > TITLE_MAX) {
|
|
64
|
+
return [{ title: `Title is ${title.length} characters and will be truncated (aim for ${TITLE_MIN}–${TITLE_MAX})` }];
|
|
65
|
+
}
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const titleDuplicate = {
|
|
70
|
+
id: "seo.title-duplicate",
|
|
71
|
+
agent: "seo",
|
|
72
|
+
severity: "warning",
|
|
73
|
+
run: (ctx) => {
|
|
74
|
+
const title = effectiveTitle(ctx.page);
|
|
75
|
+
if (!title)
|
|
76
|
+
return [];
|
|
77
|
+
const clash = ctx.site.pages.filter((p) => p.slug !== ctx.page.slug &&
|
|
78
|
+
(p.meta?.title ?? p.title ?? "").trim().toLowerCase() === title.toLowerCase());
|
|
79
|
+
if (clash.length === 0)
|
|
80
|
+
return [];
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
title: "Another page has the same title",
|
|
84
|
+
detail: `Also used by ${clash.map((p) => p.slug).join(", ")}. Search engines pick one and drop the rest.`
|
|
85
|
+
}
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const descriptionMissing = {
|
|
90
|
+
id: "seo.description-missing",
|
|
91
|
+
agent: "seo",
|
|
92
|
+
severity: "warning",
|
|
93
|
+
run: (ctx) => (ctx.page.meta?.description ?? "").trim()
|
|
94
|
+
? []
|
|
95
|
+
: [
|
|
96
|
+
{
|
|
97
|
+
title: "Page has no description",
|
|
98
|
+
// No proposedOps: writing a good one is a judgement-tier job. A
|
|
99
|
+
// rule that guessed here would ship copy under an approval button
|
|
100
|
+
// people have learned to click.
|
|
101
|
+
detail: "Search results and link previews will fall back to whatever text they can scrape."
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
};
|
|
105
|
+
const descriptionLength = {
|
|
106
|
+
id: "seo.description-length",
|
|
107
|
+
agent: "seo",
|
|
108
|
+
severity: "info",
|
|
109
|
+
run: (ctx) => {
|
|
110
|
+
const description = (ctx.page.meta?.description ?? "").trim();
|
|
111
|
+
if (!description)
|
|
112
|
+
return [];
|
|
113
|
+
if (description.length < DESCRIPTION_MIN) {
|
|
114
|
+
return [{ title: `Description is ${description.length} characters (aim for ${DESCRIPTION_MIN}–${DESCRIPTION_MAX})` }];
|
|
115
|
+
}
|
|
116
|
+
if (description.length > DESCRIPTION_MAX) {
|
|
117
|
+
return [{ title: `Description is ${description.length} characters and will be truncated (aim for ${DESCRIPTION_MIN}–${DESCRIPTION_MAX})` }];
|
|
118
|
+
}
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const ogImageMissing = {
|
|
123
|
+
id: "seo.og-image-missing",
|
|
124
|
+
agent: "seo",
|
|
125
|
+
severity: "info",
|
|
126
|
+
run: (ctx) => {
|
|
127
|
+
if ((ctx.page.meta?.ogImage ?? "").trim())
|
|
128
|
+
return [];
|
|
129
|
+
const hasImage = ctx.fields.some((f) => f.kind === "image" && nonEmpty(f.value) && f.value !== IMAGE_PLACEHOLDER);
|
|
130
|
+
return [
|
|
131
|
+
{
|
|
132
|
+
title: "No social preview image",
|
|
133
|
+
detail: hasImage
|
|
134
|
+
? "The page has images but none is set as meta.ogImage, so shares get no thumbnail."
|
|
135
|
+
: "Shared links will render without a thumbnail."
|
|
136
|
+
}
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const slugQuality = {
|
|
141
|
+
id: "seo.slug-quality",
|
|
142
|
+
agent: "seo",
|
|
143
|
+
severity: "info",
|
|
144
|
+
run: (ctx) => {
|
|
145
|
+
const slug = ctx.page.slug;
|
|
146
|
+
const problems = [];
|
|
147
|
+
if (/[A-Z]/.test(slug))
|
|
148
|
+
problems.push("uppercase letters");
|
|
149
|
+
if (slug.includes("_"))
|
|
150
|
+
problems.push("underscores instead of hyphens");
|
|
151
|
+
if (/\s/.test(slug))
|
|
152
|
+
problems.push("spaces");
|
|
153
|
+
if (slug.split("/").filter(Boolean).length > 5)
|
|
154
|
+
problems.push("more than five segments");
|
|
155
|
+
if (problems.length === 0)
|
|
156
|
+
return [];
|
|
157
|
+
return [{ title: `Slug has ${problems.join(", ")}`, detail: slug }];
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Structure
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
/** Heading levels in block order, from any field the manifest calls a headingLevel. */
|
|
164
|
+
function headingLevels(ctx) {
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const field of ctx.fields) {
|
|
167
|
+
if (field.kind !== "headingLevel")
|
|
168
|
+
continue;
|
|
169
|
+
const level = typeof field.value === "number" ? field.value : Number(field.value);
|
|
170
|
+
if (Number.isFinite(level) && level >= 1 && level <= 6)
|
|
171
|
+
out.push({ field, level });
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
const h1Count = {
|
|
176
|
+
id: "seo.h1-count",
|
|
177
|
+
agent: "seo",
|
|
178
|
+
severity: "warning",
|
|
179
|
+
run: (ctx) => {
|
|
180
|
+
const levels = headingLevels(ctx);
|
|
181
|
+
// A page whose blocks declare no heading level at all is not making a
|
|
182
|
+
// claim about its structure — most likely the site's blocks hardcode their
|
|
183
|
+
// tags. Reporting "no h1" there is a guess dressed as a fact.
|
|
184
|
+
if (levels.length === 0)
|
|
185
|
+
return [];
|
|
186
|
+
const h1s = levels.filter((l) => l.level === 1);
|
|
187
|
+
if (h1s.length === 1)
|
|
188
|
+
return [];
|
|
189
|
+
if (h1s.length === 0) {
|
|
190
|
+
return [{ title: "Page has no top-level heading", detail: "No block on the page is set to heading level 1." }];
|
|
191
|
+
}
|
|
192
|
+
// Keyed by field, not by block: a block may declare more than one heading
|
|
193
|
+
// level (a two-column with a heading each), and two findings sharing one
|
|
194
|
+
// key collapse into one — leaving the second heading with no finding and
|
|
195
|
+
// no proposed fix.
|
|
196
|
+
return h1s.slice(1).map(({ field }) => ({
|
|
197
|
+
key: `${field.blockId}:${field.path}`,
|
|
198
|
+
title: "Page has more than one top-level heading",
|
|
199
|
+
detail: `${h1s.length} blocks are set to heading level 1.`,
|
|
200
|
+
evidence: evidenceFor(field),
|
|
201
|
+
proposedOps: [
|
|
202
|
+
{
|
|
203
|
+
op: "update_props",
|
|
204
|
+
pageSlug: ctx.page.slug,
|
|
205
|
+
blockId: field.blockId,
|
|
206
|
+
patch: { [field.path]: 2 }
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const headingOrder = {
|
|
213
|
+
id: "seo.heading-order",
|
|
214
|
+
agent: "seo",
|
|
215
|
+
severity: "info",
|
|
216
|
+
run: (ctx) => {
|
|
217
|
+
const levels = headingLevels(ctx);
|
|
218
|
+
const out = [];
|
|
219
|
+
let previous = null;
|
|
220
|
+
for (const { field, level } of levels) {
|
|
221
|
+
if (previous !== null && level > previous + 1) {
|
|
222
|
+
out.push({
|
|
223
|
+
key: `${field.blockId}:${field.path}`,
|
|
224
|
+
title: `Heading jumps from level ${previous} to ${level}`,
|
|
225
|
+
detail: "Screen readers announce the gap as a missing section.",
|
|
226
|
+
evidence: evidenceFor(field)
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
previous = level;
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const thinContent = {
|
|
235
|
+
id: "seo.thin-content",
|
|
236
|
+
agent: "seo",
|
|
237
|
+
severity: "info",
|
|
238
|
+
run: (ctx) => {
|
|
239
|
+
const total = textFields(ctx)
|
|
240
|
+
.map((f) => fieldText(f.value).trim())
|
|
241
|
+
.join(" ")
|
|
242
|
+
.trim();
|
|
243
|
+
if (total.length >= THIN_CONTENT_CHARS)
|
|
244
|
+
return [];
|
|
245
|
+
return [
|
|
246
|
+
{
|
|
247
|
+
title: `Page has ${total.length} characters of text`,
|
|
248
|
+
detail: `Under ${THIN_CONTENT_CHARS} characters is usually too little for a page to rank for anything.`
|
|
249
|
+
}
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Links, images, leftovers
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
const internalLinkDead = {
|
|
257
|
+
id: "seo.internal-link-dead",
|
|
258
|
+
agent: "seo",
|
|
259
|
+
severity: "warning",
|
|
260
|
+
run: (ctx) => {
|
|
261
|
+
const known = new Set(ctx.site.slugs);
|
|
262
|
+
return ctx.fields
|
|
263
|
+
/*
|
|
264
|
+
* `link` is the kind that means "navigation target"; `url` is still
|
|
265
|
+
* accepted because a custom block's manifest may declare either, and a
|
|
266
|
+
* dead link is a dead link whichever way it was tagged. `parseLink`
|
|
267
|
+
* screens out mailto/tel/anchors/external and the default "/", so only a
|
|
268
|
+
* real route reaches `isKnownRoute` — the same matcher the editor's link
|
|
269
|
+
* field uses inline, so the panel and the field cannot disagree.
|
|
270
|
+
*/
|
|
271
|
+
.filter((f) => f.kind === "link" || f.kind === "url")
|
|
272
|
+
/*
|
|
273
|
+
* Absolute routes only, as before. A bare "pricing" is a relative link
|
|
274
|
+
* and broken from any page but the root, but flagging it is a separate
|
|
275
|
+
* judgement call from this rule's, and one that would light up existing
|
|
276
|
+
* sites without warning.
|
|
277
|
+
*/
|
|
278
|
+
.filter((f) => parseLink(f.value).path?.startsWith("/") === true)
|
|
279
|
+
.filter((f) => !isKnownRoute(String(f.value), known))
|
|
280
|
+
.map((field) => ({
|
|
281
|
+
key: field.path === "" ? field.blockId : `${field.blockId}:${field.path}`,
|
|
282
|
+
title: `Link points at a page that does not exist`,
|
|
283
|
+
detail: String(field.value),
|
|
284
|
+
evidence: evidenceFor(field, String(field.value))
|
|
285
|
+
}));
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
const altMissing = {
|
|
289
|
+
id: "a11y.alt-missing",
|
|
290
|
+
agent: "a11y",
|
|
291
|
+
severity: "warning",
|
|
292
|
+
run: (ctx) => {
|
|
293
|
+
const out = [];
|
|
294
|
+
/*
|
|
295
|
+
* One block at a time. Paths and containers are block-relative, so a
|
|
296
|
+
* page-wide index of either pairs an image in one block with the alt text
|
|
297
|
+
* of another — and two blocks of the same type on one page is the ordinary
|
|
298
|
+
* case, not the exotic one.
|
|
299
|
+
*
|
|
300
|
+
* All three ways that went wrong were silent: two heroes where the first
|
|
301
|
+
* had no alt text reported nothing at all; the mirror image reported the
|
|
302
|
+
* problem twice, both times pointing "Go to" at the wrong block.
|
|
303
|
+
*/
|
|
304
|
+
for (const fields of groupByBlock(ctx.fields).values()) {
|
|
305
|
+
const alts = fields.filter((f) => f.kind === "imageAlt");
|
|
306
|
+
const altByPath = new Map(alts.map((f) => [f.path, f]));
|
|
307
|
+
const altsByContainer = new Map();
|
|
308
|
+
for (const field of alts) {
|
|
309
|
+
const list = altsByContainer.get(field.container) ?? [];
|
|
310
|
+
list.push(field);
|
|
311
|
+
altsByContainer.set(field.container, list);
|
|
312
|
+
}
|
|
313
|
+
for (const image of fields) {
|
|
314
|
+
if (image.kind !== "image" || !nonEmpty(image.value))
|
|
315
|
+
continue;
|
|
316
|
+
// The repo's own naming convention first (`imageUrl` → `imageAlt`,
|
|
317
|
+
// `.src` → `.alt`), then the structural answer: a lone alt field in the
|
|
318
|
+
// same container. A container with two images and two alts is ambiguous
|
|
319
|
+
// and is left alone rather than guessed at.
|
|
320
|
+
const byConvention = altByPath.get(toAltPath(image.path));
|
|
321
|
+
const siblings = altsByContainer.get(image.container) ?? [];
|
|
322
|
+
const alt = byConvention ?? (siblings.length === 1 ? siblings[0] : undefined);
|
|
323
|
+
if (!alt || nonEmpty(alt.value))
|
|
324
|
+
continue;
|
|
325
|
+
out.push({
|
|
326
|
+
key: `${image.blockId}:${image.path}`,
|
|
327
|
+
title: `Image has no alt text`,
|
|
328
|
+
detail: `${image.label ?? image.path} is set but its alt text is empty.`,
|
|
329
|
+
evidence: evidenceFor(alt)
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
const unfinished = {
|
|
337
|
+
id: "content.unfinished",
|
|
338
|
+
agent: "content",
|
|
339
|
+
severity: "warning",
|
|
340
|
+
run: (ctx) => {
|
|
341
|
+
const out = [];
|
|
342
|
+
for (const field of ctx.fields) {
|
|
343
|
+
// `defaultScalarForField` writes exactly these when a block is scaffolded,
|
|
344
|
+
// so this is an equality test, not a heuristic — the product is the only
|
|
345
|
+
// thing that could have produced the string.
|
|
346
|
+
const isScaffoldText = (field.kind === "text" || field.kind === "richtext" || field.kind === "imageAlt") &&
|
|
347
|
+
fieldText(field.value).trim() === `New ${field.label ?? field.path}`;
|
|
348
|
+
const isScaffoldImage = field.kind === "image" && field.value === IMAGE_PLACEHOLDER;
|
|
349
|
+
if (!isScaffoldText && !isScaffoldImage)
|
|
350
|
+
continue;
|
|
351
|
+
out.push({
|
|
352
|
+
key: `${field.blockId}:${field.path}`,
|
|
353
|
+
title: `${field.label ?? field.path} was never filled in`,
|
|
354
|
+
detail: "This is still the placeholder the block was created with.",
|
|
355
|
+
evidence: evidenceFor(field, fieldText(field.value) || String(field.value))
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return out;
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
export const DRAFT_RULES = [
|
|
362
|
+
titleMissing,
|
|
363
|
+
titleLength,
|
|
364
|
+
titleDuplicate,
|
|
365
|
+
descriptionMissing,
|
|
366
|
+
descriptionLength,
|
|
367
|
+
ogImageMissing,
|
|
368
|
+
slugQuality,
|
|
369
|
+
h1Count,
|
|
370
|
+
headingOrder,
|
|
371
|
+
thinContent,
|
|
372
|
+
internalLinkDead,
|
|
373
|
+
altMissing,
|
|
374
|
+
unfinished
|
|
375
|
+
];
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { BlockManifest, PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
|
|
2
|
+
import type { CheckRunRecord, CheckRunTrigger, DurableStore } from "../durable/types.ts";
|
|
3
|
+
import type { CheckRule } from "./types.ts";
|
|
4
|
+
/**
|
|
5
|
+
* The fingerprint: identity of a problem, not of an occurrence of it.
|
|
6
|
+
*
|
|
7
|
+
* It is computed here, from `(scopeKey, slug, ruleId, key)`, and never by a
|
|
8
|
+
* rule — because the one thing that must not leak into it is the offending
|
|
9
|
+
* *value*. Include the value and half-fixing a title produces a second finding
|
|
10
|
+
* instead of an updated one, orphaning the first and silently voiding the
|
|
11
|
+
* dismissal somebody made last week.
|
|
12
|
+
*/
|
|
13
|
+
export declare function fingerprintFor(scopeKey: string, slug: string, ruleId: string, key?: string): string;
|
|
14
|
+
export type RunChecksArgs = {
|
|
15
|
+
scopeKey: string;
|
|
16
|
+
/** Every page in the site — not just the ones being scanned. See below. */
|
|
17
|
+
pages: PageDoc[];
|
|
18
|
+
manifest: BlockManifest;
|
|
19
|
+
siteConfig?: SiteConfig;
|
|
20
|
+
trigger?: CheckRunTrigger;
|
|
21
|
+
/**
|
|
22
|
+
* Restrict the scan to these slugs. Cross-page rules still see the whole
|
|
23
|
+
* site: an incremental run over one page must not report every *other*
|
|
24
|
+
* page's title as unique, nor every link into them as dead.
|
|
25
|
+
*/
|
|
26
|
+
slugs?: string[];
|
|
27
|
+
rules?: CheckRule[];
|
|
28
|
+
store?: DurableStore;
|
|
29
|
+
runId?: string;
|
|
30
|
+
now?: () => number;
|
|
31
|
+
};
|
|
32
|
+
export declare function runDraftChecks(args: RunChecksArgs): Promise<CheckRunRecord>;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { getDurableStore } from "../durable/durable-store-singleton.js";
|
|
3
|
+
import { NEUTRAL_PAGE_WEIGHT, impactFor } from "../durable/finding-impact.js";
|
|
4
|
+
import { walkPageFields } from "./field-walk.js";
|
|
5
|
+
import { computePageWeights } from "./page-weight.js";
|
|
6
|
+
import { DRAFT_RULES } from "./rules-draft.js";
|
|
7
|
+
/**
|
|
8
|
+
* The fingerprint: identity of a problem, not of an occurrence of it.
|
|
9
|
+
*
|
|
10
|
+
* It is computed here, from `(scopeKey, slug, ruleId, key)`, and never by a
|
|
11
|
+
* rule — because the one thing that must not leak into it is the offending
|
|
12
|
+
* *value*. Include the value and half-fixing a title produces a second finding
|
|
13
|
+
* instead of an updated one, orphaning the first and silently voiding the
|
|
14
|
+
* dismissal somebody made last week.
|
|
15
|
+
*/
|
|
16
|
+
export function fingerprintFor(scopeKey, slug, ruleId, key = "") {
|
|
17
|
+
return createHash("sha256").update([scopeKey, slug, ruleId, key].join("\u0000")).digest("hex").slice(0, 32);
|
|
18
|
+
}
|
|
19
|
+
export async function runDraftChecks(args) {
|
|
20
|
+
const store = args.store ?? getDurableStore();
|
|
21
|
+
const now = args.now ?? Date.now;
|
|
22
|
+
const rules = args.rules ?? DRAFT_RULES;
|
|
23
|
+
const runId = args.runId ?? randomUUID();
|
|
24
|
+
const startedAt = now();
|
|
25
|
+
const site = {
|
|
26
|
+
slugs: args.pages.map((p) => p.slug),
|
|
27
|
+
pages: args.pages.map((p) => ({ slug: p.slug, title: p.title, ...(p.meta ? { meta: p.meta } : {}) })),
|
|
28
|
+
config: args.siteConfig ?? {}
|
|
29
|
+
};
|
|
30
|
+
const wanted = args.slugs ? new Set(args.slugs) : null;
|
|
31
|
+
const scanned = args.pages.filter((p) => (wanted ? wanted.has(p.slug) : true));
|
|
32
|
+
/*
|
|
33
|
+
* Every page is walked, not only the scanned ones.
|
|
34
|
+
*
|
|
35
|
+
* Page weight is a property of the site's link graph, so scoring it from the
|
|
36
|
+
* scanned subset would make a finding's impact depend on which run last
|
|
37
|
+
* touched it — the same finding worth 0.6 after a full sweep and 0.1 after an
|
|
38
|
+
* incremental one, with the panel resorting itself for no visible reason.
|
|
39
|
+
*
|
|
40
|
+
* The walk is pure in-memory work over data already held, and the scanned
|
|
41
|
+
* pages reuse their entry rather than being walked a second time.
|
|
42
|
+
*/
|
|
43
|
+
const fieldsBySlug = new Map();
|
|
44
|
+
for (const page of args.pages)
|
|
45
|
+
fieldsBySlug.set(page.slug, walkPageFields(page, args.manifest));
|
|
46
|
+
const weights = computePageWeights({ pages: site.pages, fieldsBySlug, config: site.config });
|
|
47
|
+
await store.startCheckRun({
|
|
48
|
+
id: runId,
|
|
49
|
+
scopeKey: args.scopeKey,
|
|
50
|
+
agent: rules.length === 1 ? rules[0].agent : "checks",
|
|
51
|
+
trigger: args.trigger ?? "manual",
|
|
52
|
+
startedAt
|
|
53
|
+
});
|
|
54
|
+
const findings = [];
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
let error;
|
|
57
|
+
try {
|
|
58
|
+
for (const page of scanned) {
|
|
59
|
+
const pageWeight = weights.get(page.slug)?.weight ?? NEUTRAL_PAGE_WEIGHT;
|
|
60
|
+
const ctx = {
|
|
61
|
+
scopeKey: args.scopeKey,
|
|
62
|
+
page,
|
|
63
|
+
site,
|
|
64
|
+
manifest: args.manifest,
|
|
65
|
+
fields: fieldsBySlug.get(page.slug) ?? []
|
|
66
|
+
};
|
|
67
|
+
for (const rule of rules) {
|
|
68
|
+
// One rule throwing must not cost the run every other rule's findings —
|
|
69
|
+
// a checker that goes dark because a custom block had an unexpected
|
|
70
|
+
// prop shape is worse than one that reports twelve of thirteen rules.
|
|
71
|
+
let produced;
|
|
72
|
+
try {
|
|
73
|
+
produced = rule.run(ctx);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
error ??= `${rule.id}: ${err instanceof Error ? err.message : String(err)}`;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (const finding of produced) {
|
|
80
|
+
const fingerprint = fingerprintFor(args.scopeKey, page.slug, rule.id, finding.key);
|
|
81
|
+
/*
|
|
82
|
+
* Two findings from one rule sharing a key are one finding as far as
|
|
83
|
+
* the store is concerned — the second upsert overwrites the first.
|
|
84
|
+
* Collapsing them here instead keeps the ledger honest (the second
|
|
85
|
+
* was being counted as an `updated` row) and makes which one survives
|
|
86
|
+
* a decision rather than an accident of iteration order.
|
|
87
|
+
*/
|
|
88
|
+
if (seen.has(fingerprint))
|
|
89
|
+
continue;
|
|
90
|
+
seen.add(fingerprint);
|
|
91
|
+
const severity = finding.severity ?? rule.severity;
|
|
92
|
+
findings.push({
|
|
93
|
+
fingerprint,
|
|
94
|
+
scopeKey: args.scopeKey,
|
|
95
|
+
slug: page.slug,
|
|
96
|
+
ruleId: rule.id,
|
|
97
|
+
agent: rule.agent,
|
|
98
|
+
severity,
|
|
99
|
+
impact: impactFor(severity, pageWeight),
|
|
100
|
+
title: finding.title,
|
|
101
|
+
...(finding.detail ? { detail: finding.detail } : {}),
|
|
102
|
+
...(finding.evidence ? { evidence: finding.evidence } : {}),
|
|
103
|
+
...(finding.proposedOps ? { proposedOps: finding.proposedOps } : {})
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const { opened } = await store.recordFindings(runId, findings);
|
|
109
|
+
// Reconcile per agent, not once for the whole run. A run of only the SEO
|
|
110
|
+
// rules that closed everything in scope would mark this morning's
|
|
111
|
+
// accessibility findings fixed without having looked at them.
|
|
112
|
+
const scannedSlugs = scanned.map((p) => p.slug);
|
|
113
|
+
let closed = 0;
|
|
114
|
+
for (const agent of new Set(rules.map((r) => r.agent))) {
|
|
115
|
+
const result = await store.reconcileFindings({
|
|
116
|
+
runId,
|
|
117
|
+
scopeKey: args.scopeKey,
|
|
118
|
+
slugs: scannedSlugs,
|
|
119
|
+
agent,
|
|
120
|
+
at: now()
|
|
121
|
+
});
|
|
122
|
+
closed += result.closed;
|
|
123
|
+
}
|
|
124
|
+
const record = {
|
|
125
|
+
id: runId,
|
|
126
|
+
scopeKey: args.scopeKey,
|
|
127
|
+
agent: rules.length === 1 ? rules[0].agent : "checks",
|
|
128
|
+
trigger: args.trigger ?? "manual",
|
|
129
|
+
startedAt,
|
|
130
|
+
finishedAt: now(),
|
|
131
|
+
pagesScanned: scanned.length,
|
|
132
|
+
findingsOpened: opened,
|
|
133
|
+
findingsClosed: closed,
|
|
134
|
+
costUsd: 0,
|
|
135
|
+
...(error ? { error } : {})
|
|
136
|
+
};
|
|
137
|
+
await store.finishCheckRun(runId, {
|
|
138
|
+
finishedAt: record.finishedAt,
|
|
139
|
+
pagesScanned: record.pagesScanned,
|
|
140
|
+
findingsOpened: record.findingsOpened,
|
|
141
|
+
findingsClosed: record.findingsClosed,
|
|
142
|
+
costUsd: 0,
|
|
143
|
+
...(error ? { error } : {})
|
|
144
|
+
});
|
|
145
|
+
return record;
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
149
|
+
await store.finishCheckRun(runId, { finishedAt: now(), pagesScanned: scanned.length, error: reason });
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CheckRunRecord, CheckRunTrigger } from "../durable/types.ts";
|
|
2
|
+
import type { Logger } from "../logger.ts";
|
|
3
|
+
export declare function runChecksForSession(args: {
|
|
4
|
+
scopeKey: string;
|
|
5
|
+
trigger: CheckRunTrigger;
|
|
6
|
+
slugs?: string[];
|
|
7
|
+
}): Promise<CheckRunRecord>;
|
|
8
|
+
/**
|
|
9
|
+
* Queue a draft-tier run after an apply, coalescing a burst of edits into one.
|
|
10
|
+
*
|
|
11
|
+
* Debounced rather than throttled: the useful moment is after someone stops
|
|
12
|
+
* typing, not in the middle of a streamed multi-op plan where half the ops have
|
|
13
|
+
* landed and the page is transiently wrong.
|
|
14
|
+
*/
|
|
15
|
+
export declare function scheduleChecksAfterApply(scopeKey: string, log?: Logger): void;
|
|
16
|
+
/** Run the draft tier after a successful publish. */
|
|
17
|
+
export declare function scheduleChecksAfterPublish(scopeKey: string, log?: Logger): void;
|
|
18
|
+
/** Cancel any queued run. Tests, and graceful shutdown. */
|
|
19
|
+
export declare function cancelScheduledChecks(): void;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { buildBlockManifest } from "@avocadostudio-ai/shared";
|
|
2
|
+
import { getSessionDraft, getSiteConfig } from "../state/session-state.js";
|
|
3
|
+
import { runDraftChecks } from "./run-checks.js";
|
|
4
|
+
/*
|
|
5
|
+
* Binds the pure rules engine to session state.
|
|
6
|
+
*
|
|
7
|
+
* `run-checks.ts` deliberately takes pages and a manifest as arguments and
|
|
8
|
+
* touches no globals — it is testable against invented block types and an
|
|
9
|
+
* invented site. This is the one place that reaches for the real ones, so both
|
|
10
|
+
* the HTTP action and the triggers below run identical code.
|
|
11
|
+
*/
|
|
12
|
+
export async function runChecksForSession(args) {
|
|
13
|
+
return runDraftChecks({
|
|
14
|
+
scopeKey: args.scopeKey,
|
|
15
|
+
pages: [...getSessionDraft(args.scopeKey).values()],
|
|
16
|
+
// The registry of *this* process — in library mode, the host's own
|
|
17
|
+
// `registerBlocks()`. Same reason `blocksManifestAction` uses it: a rule
|
|
18
|
+
// reading our built-ins would be blind to the site's real vocabulary.
|
|
19
|
+
manifest: buildBlockManifest(),
|
|
20
|
+
siteConfig: getSiteConfig(args.scopeKey),
|
|
21
|
+
trigger: args.trigger,
|
|
22
|
+
...(args.slugs?.length ? { slugs: args.slugs } : {})
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Triggers
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/*
|
|
29
|
+
* What wakes a check run, and why the defaults are what they are.
|
|
30
|
+
*
|
|
31
|
+
* `on_publish` is on by default: the draft tier is a few milliseconds of
|
|
32
|
+
* in-memory work, it costs nothing, and the moment content ships is when
|
|
33
|
+
* anybody cares whether it is broken.
|
|
34
|
+
*
|
|
35
|
+
* `on_apply` is off by default, behind `CHECKS_ON_APPLY=1`. It is the one that
|
|
36
|
+
* fires on every edit, and this repo has already paid once for a fan-out
|
|
37
|
+
* nobody intended — an ambient linter should be something an operator turns on
|
|
38
|
+
* having decided to, not something they discover in a CPU graph.
|
|
39
|
+
*
|
|
40
|
+
* Both are inert under NODE_ENV=test: the hermetic suite must not have a
|
|
41
|
+
* background task writing findings into a store its assertions are reading.
|
|
42
|
+
*/
|
|
43
|
+
const ON_APPLY_DEBOUNCE_MS = 2_000;
|
|
44
|
+
const pending = new Map();
|
|
45
|
+
function enabled(flag) {
|
|
46
|
+
if (process.env.NODE_ENV === "test")
|
|
47
|
+
return false;
|
|
48
|
+
if (flag === "apply")
|
|
49
|
+
return process.env.CHECKS_ON_APPLY === "1";
|
|
50
|
+
return process.env.CHECKS_ON_PUBLISH !== "0";
|
|
51
|
+
}
|
|
52
|
+
function runInBackground(scopeKey, trigger, log) {
|
|
53
|
+
void runChecksForSession({ scopeKey, trigger })
|
|
54
|
+
.then((run) => {
|
|
55
|
+
log?.info({ scopeKey, trigger, opened: run.findingsOpened, closed: run.findingsClosed }, "checks run complete");
|
|
56
|
+
})
|
|
57
|
+
.catch((err) => {
|
|
58
|
+
// A checker must never be able to fail the edit or the publish that
|
|
59
|
+
// triggered it. It reports and stops.
|
|
60
|
+
log?.error({ err: String(err), scopeKey, trigger }, "checks run failed");
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Queue a draft-tier run after an apply, coalescing a burst of edits into one.
|
|
65
|
+
*
|
|
66
|
+
* Debounced rather than throttled: the useful moment is after someone stops
|
|
67
|
+
* typing, not in the middle of a streamed multi-op plan where half the ops have
|
|
68
|
+
* landed and the page is transiently wrong.
|
|
69
|
+
*/
|
|
70
|
+
export function scheduleChecksAfterApply(scopeKey, log) {
|
|
71
|
+
if (!enabled("apply"))
|
|
72
|
+
return;
|
|
73
|
+
const existing = pending.get(scopeKey);
|
|
74
|
+
if (existing)
|
|
75
|
+
clearTimeout(existing);
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
pending.delete(scopeKey);
|
|
78
|
+
runInBackground(scopeKey, "on_apply", log);
|
|
79
|
+
}, ON_APPLY_DEBOUNCE_MS);
|
|
80
|
+
// Do not hold the process open for a linter.
|
|
81
|
+
timer.unref?.();
|
|
82
|
+
pending.set(scopeKey, timer);
|
|
83
|
+
}
|
|
84
|
+
/** Run the draft tier after a successful publish. */
|
|
85
|
+
export function scheduleChecksAfterPublish(scopeKey, log) {
|
|
86
|
+
if (!enabled("publish"))
|
|
87
|
+
return;
|
|
88
|
+
runInBackground(scopeKey, "on_publish", log);
|
|
89
|
+
}
|
|
90
|
+
/** Cancel any queued run. Tests, and graceful shutdown. */
|
|
91
|
+
export function cancelScheduledChecks() {
|
|
92
|
+
for (const timer of pending.values())
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
pending.clear();
|
|
95
|
+
}
|