@nextcommerce/campaigns-os 1.46.0 → 1.48.0
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 +347 -3
- package/README.md +31 -4
- package/agents/claude/CLAUDE.md +2 -0
- package/agents/codex/AGENTS.md +1 -0
- package/agents/copilot/copilot-instructions.md +1 -0
- package/agents/cursor/campaigns-os.mdc +1 -1
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +1204 -129
- package/contracts/effects.v1.json +3 -3
- package/contracts/release-ledger.json +803 -0
- package/contracts/supported-surface.json +2 -2
- package/contracts/template-brand-contract.shared-commerce.v0.json +3 -3
- package/docs/build-packet.md +23 -3
- package/docs/campaign-build-brief.md +25 -28
- package/docs/local-setup.md +13 -5
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +68 -3
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +9 -4
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -4
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +4 -4
- package/skills/next-campaigns-qa/SKILL.md +4 -4
- package/skills.json +10 -10
- package/src/built-script-syntax.mjs +116 -15
- package/src/cli.mjs +6 -3
- package/src/content-residue.mjs +18 -90
- package/src/diagnostic.mjs +2 -1
- package/src/doctor/checks.mjs +21 -30
- package/src/doctor/inspect.mjs +13 -3
- package/src/doctor/next-step.mjs +4 -0
- package/src/gate-actions.mjs +8 -0
- package/src/local-preview-policy.mjs +92 -0
- package/src/page-kit-sdk-version.mjs +8 -1
- package/src/polish-node.mjs +26 -2
- package/src/progress-node.mjs +5 -1
- package/src/qa-analytics-parity.mjs +37 -2
- package/src/qa-binding-evidence.mjs +5 -3
- package/src/qa-browser.mjs +136 -25
- package/src/qa-node.mjs +33 -4
- package/src/readback.mjs +19 -10
- package/src/sdk-markup.mjs +6 -45
- package/src/sdk-storage-compatibility.mjs +3 -2
- package/src/source-prep.mjs +1 -1
- package/src/tooling-setup.mjs +9 -0
package/skills.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "campaigns-os-skills",
|
|
3
3
|
"description": "Skills bundled with Campaigns OS. `skills.sh` installs these into the shared agent skill directories (~/.claude/skills, ~/.codex/skills), so each one is a versioned package: bump the version whenever a package changes. `bundle_revision` identifies the bundle as a whole — `<package version>+skills.<n>`, where the prefix is this package's version and `<n>` counts the skill-text revisions published against it. It advances whenever ANY bundled skill changes, every SKILL.md states it on its first body line, and `campaigns-os tooling status --skills-revision <value>` compares the value an agent read from a skill against the bundle on disk. See docs/skills-revision.md.",
|
|
4
|
-
"bundle_revision": "1.
|
|
4
|
+
"bundle_revision": "1.48.0+skills.1",
|
|
5
5
|
"homepage": "https://github.com/NextCommerceCo/campaigns-os",
|
|
6
6
|
"retired_skills": [
|
|
7
7
|
{
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"id": "next-campaigns-os",
|
|
18
18
|
"name": "Campaigns OS Lifecycle",
|
|
19
|
-
"version": "1.0.
|
|
19
|
+
"version": "1.0.42",
|
|
20
20
|
"path": "skills/next-campaigns-os/SKILL.md",
|
|
21
21
|
"domain": "campaigns",
|
|
22
22
|
"description": "Coordinate Campaigns OS lifecycle workflows from CampaignSpec, Build Packet, starter-template contracts, stage reports, deploy evidence, and QA proof depth."
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
{
|
|
25
25
|
"id": "next-campaigns-os-setup",
|
|
26
26
|
"name": "Campaigns OS Setup",
|
|
27
|
-
"version": "2.0.
|
|
27
|
+
"version": "2.0.25",
|
|
28
28
|
"path": "skills/next-campaigns-os-setup/SKILL.md",
|
|
29
29
|
"domain": "campaigns",
|
|
30
30
|
"description": "Bootstrap or prepare a target page-kit campaign repo from a doctor-cleared Campaigns OS Build Packet before full build wiring. Formerly next-campaigns-setup; renamed to release that name to the published NextCommerceCo/skills scaffolder."
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
{
|
|
33
33
|
"id": "next-campaigns-build",
|
|
34
34
|
"name": "Campaign Build",
|
|
35
|
-
"version": "1.0.
|
|
35
|
+
"version": "1.0.27",
|
|
36
36
|
"path": "skills/next-campaigns-build/SKILL.md",
|
|
37
37
|
"domain": "campaigns",
|
|
38
38
|
"description": "Assemble a NEXT campaign from a doctor-cleared Build Packet, CampaignSpec/API values, prepared HTML/assets, page-kit, and starter-template contracts."
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
{
|
|
41
41
|
"id": "next-campaigns-polish",
|
|
42
42
|
"name": "Campaign Polish",
|
|
43
|
-
"version": "1.1.
|
|
43
|
+
"version": "1.1.26",
|
|
44
44
|
"path": "skills/next-campaigns-polish/SKILL.md",
|
|
45
45
|
"domain": "campaigns",
|
|
46
46
|
"description": "Run the visual/runtime polish pass after build and before QA for a Campaigns OS campaign."
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
{
|
|
49
49
|
"id": "next-campaigns-qa",
|
|
50
50
|
"name": "Campaign QA",
|
|
51
|
-
"version": "1.3.
|
|
51
|
+
"version": "1.3.26",
|
|
52
52
|
"path": "skills/next-campaigns-qa/SKILL.md",
|
|
53
53
|
"domain": "campaigns",
|
|
54
54
|
"description": "Run spec-aware QA from a saved Map or local-spec Build Packet and tested campaign URL after build, polish, and deploy/local evidence exist, including Playwright typed-card test-order proof."
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
{
|
|
57
57
|
"id": "campaign-lifecycle-orientation",
|
|
58
58
|
"name": "Campaign Lifecycle Orientation",
|
|
59
|
-
"version": "1.0.
|
|
59
|
+
"version": "1.0.22",
|
|
60
60
|
"path": "skills/campaign-lifecycle-orientation/SKILL.md",
|
|
61
61
|
"domain": "campaigns",
|
|
62
62
|
"description": "Orient a reader to the Campaigns OS lifecycle artifacts a run has already emitted, without advancing their state: the shared vocabulary, doctor as a gate, the stage record, and the store-theme/Page Kit two-worlds trap."
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
{
|
|
65
65
|
"id": "campaign-run-evidence",
|
|
66
66
|
"name": "Campaign Run Evidence",
|
|
67
|
-
"version": "1.0.
|
|
67
|
+
"version": "1.0.22",
|
|
68
68
|
"path": "skills/campaign-run-evidence/SKILL.md",
|
|
69
69
|
"domain": "campaigns",
|
|
70
70
|
"description": "Interpret existing doctor, QA verdict and proof-depth evidence without claiming more proof than the artifacts contain, and keep funnel proof apart from merchant launch readiness."
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
{
|
|
73
73
|
"id": "campaign-readback-classification",
|
|
74
74
|
"name": "Campaign Readback Classification",
|
|
75
|
-
"version": "1.0.
|
|
75
|
+
"version": "1.0.22",
|
|
76
76
|
"path": "skills/campaign-readback-classification/SKILL.md",
|
|
77
77
|
"domain": "campaigns",
|
|
78
78
|
"description": "Classify a selected campaign from the campaigns-os-readback/v2 fields (artifacts, staleness.stale_keys, clean, doctor, divergences, skip_cascades) and write a read-only handoff without turning diagnosis into permission."
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
{
|
|
81
81
|
"id": "contribution-intake",
|
|
82
82
|
"name": "Contribution Intake",
|
|
83
|
-
"version": "1.0.
|
|
83
|
+
"version": "1.0.22",
|
|
84
84
|
"path": "skills/contribution-intake/SKILL.md",
|
|
85
85
|
"domain": "campaigns",
|
|
86
86
|
"description": "Turn a suggestion about the agent surface into a classified, evidence-checked and redacted proposal, filed on this repository tracker only with attended approval."
|
|
@@ -17,10 +17,17 @@
|
|
|
17
17
|
// nothing it would define runs, but whether the page needs it is not known
|
|
18
18
|
// here, so it does not block.
|
|
19
19
|
//
|
|
20
|
+
// Two shapes are not read and only warn (#515). A `<script>` left unclosed at
|
|
21
|
+
// the end of the file never runs: the browser does not prepare a script
|
|
22
|
+
// element whose end tag never arrives. And a script that is a symlink, or
|
|
23
|
+
// sits under a symlinked directory, resolving outside the site root: a static
|
|
24
|
+
// server would follow it, but its bytes are not part of the built output. A
|
|
25
|
+
// symlink that stays inside the site root is read where it points.
|
|
26
|
+
//
|
|
20
27
|
// Not waivable: a script that cannot be parsed cannot be intended to ship.
|
|
21
28
|
// Both doctor entry points drive it, like the other static built-output gates.
|
|
22
29
|
|
|
23
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
30
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
24
31
|
import { basename, isAbsolute, posix, relative, resolve, sep } from "node:path";
|
|
25
32
|
|
|
26
33
|
import { parse as parseJs } from "acorn";
|
|
@@ -29,6 +36,8 @@ import { parse as parseHtml } from "parse5";
|
|
|
29
36
|
export const SCRIPT_SYNTAX = "built_output.script_syntax";
|
|
30
37
|
export const SCRIPT_SYNTAX_PARSE_FAILURE = `${SCRIPT_SYNTAX}.parse_failure`;
|
|
31
38
|
export const SCRIPT_SYNTAX_MISSING_SCRIPT = `${SCRIPT_SYNTAX}.missing_script`;
|
|
39
|
+
export const SCRIPT_SYNTAX_UNCLOSED_SCRIPT = `${SCRIPT_SYNTAX}.unclosed_script`;
|
|
40
|
+
export const SCRIPT_SYNTAX_SYMLINK_OUTSIDE_SITE = `${SCRIPT_SYNTAX}.symlink_outside_site`;
|
|
32
41
|
|
|
33
42
|
// Classic script MIME types the browser executes. Anything else with a type
|
|
34
43
|
// attribute (JSON-LD, text/template, importmap) is a data block, not script.
|
|
@@ -200,6 +209,17 @@ export function baseInEffect(bases, scriptNode) {
|
|
|
200
209
|
return base ? base.href : null;
|
|
201
210
|
}
|
|
202
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Whether the file ended inside this parse5 element, before its end tag. The
|
|
214
|
+
* parser prepares a script at its end tag; at end of file it marks the element
|
|
215
|
+
* "already started" instead, so the browser never fetches or runs it.
|
|
216
|
+
*
|
|
217
|
+
* @param {object} node a parse5 element parsed with `sourceCodeLocationInfo`
|
|
218
|
+
*/
|
|
219
|
+
export function endsUnclosed(node) {
|
|
220
|
+
return Boolean(node?.sourceCodeLocation) && !node.sourceCodeLocation.endTag;
|
|
221
|
+
}
|
|
222
|
+
|
|
203
223
|
/**
|
|
204
224
|
* `<script src>` references on a page, in document order, with whether each
|
|
205
225
|
* is a module and the `<base href>` in effect when the browser prepares it
|
|
@@ -215,16 +235,21 @@ export function baseInEffect(bases, scriptNode) {
|
|
|
215
235
|
* load there. A module script ignores `nomodule` and is kept (see scriptKind).
|
|
216
236
|
* Template content and noscript are inert and not walked.
|
|
217
237
|
*
|
|
238
|
+
* A script element the file ends inside (its end tag never arrives) is never
|
|
239
|
+
* prepared, so it never runs and is not a reference. It is returned in
|
|
240
|
+
* `unclosed` instead: its src, or null for an inline script.
|
|
241
|
+
*
|
|
218
242
|
* @param {string} html
|
|
219
|
-
* @returns {{ base: string | null, refs: Array<{ src: string, module: boolean, base: string | null }> }}
|
|
243
|
+
* @returns {{ base: string | null, refs: Array<{ src: string, module: boolean, base: string | null }>, unclosed: Array<{ src: string | null }> }}
|
|
220
244
|
*/
|
|
221
245
|
export function pageScriptDocument(html) {
|
|
222
246
|
const refs = [];
|
|
247
|
+
const unclosed = [];
|
|
223
248
|
let document;
|
|
224
249
|
try {
|
|
225
250
|
document = parseHtml(String(html ?? ""), { sourceCodeLocationInfo: true });
|
|
226
251
|
} catch {
|
|
227
|
-
return { base: null, refs };
|
|
252
|
+
return { base: null, refs, unclosed };
|
|
228
253
|
}
|
|
229
254
|
const bases = documentBases(document);
|
|
230
255
|
const walk = (node) => {
|
|
@@ -233,9 +258,12 @@ export function pageScriptDocument(html) {
|
|
|
233
258
|
// href / xlink:href, and a MathML "script" is not a script element.
|
|
234
259
|
if (node.tagName === "script" && node.namespaceURI === HTML_NAMESPACE) {
|
|
235
260
|
const kind = scriptKind(attrs);
|
|
261
|
+
const hasSrc = typeof attrs.src === "string" && attrs.src !== "";
|
|
262
|
+
if (kind && endsUnclosed(node)) {
|
|
263
|
+
unclosed.push({ src: hasSrc ? stripUrlSpace(attrs.src) : null });
|
|
236
264
|
// "prepare the script element" skips only an empty src; anything else,
|
|
237
265
|
// even whitespace, is parsed as a URL and fetched.
|
|
238
|
-
if (kind &&
|
|
266
|
+
} else if (kind && hasSrc) {
|
|
239
267
|
refs.push({ src: stripUrlSpace(attrs.src), module: kind === "module", base: baseInEffect(bases, node) });
|
|
240
268
|
}
|
|
241
269
|
}
|
|
@@ -243,7 +271,7 @@ export function pageScriptDocument(html) {
|
|
|
243
271
|
for (const child of node.childNodes || []) walk(child);
|
|
244
272
|
};
|
|
245
273
|
walk(document);
|
|
246
|
-
return { base: bases[0]?.href ?? null, refs };
|
|
274
|
+
return { base: bases[0]?.href ?? null, refs, unclosed };
|
|
247
275
|
}
|
|
248
276
|
|
|
249
277
|
/**
|
|
@@ -317,6 +345,15 @@ function isRemote(src) {
|
|
|
317
345
|
return /^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//");
|
|
318
346
|
}
|
|
319
347
|
|
|
348
|
+
// The real path of a file, or null when it cannot be resolved.
|
|
349
|
+
function realPathOf(path) {
|
|
350
|
+
try {
|
|
351
|
+
return realpathSync(path);
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
320
357
|
function relFrom(root, path) {
|
|
321
358
|
const rel = relative(root, path);
|
|
322
359
|
return rel && !rel.startsWith("..") ? rel.split(sep).join("/") : path;
|
|
@@ -334,13 +371,23 @@ function relFrom(root, path) {
|
|
|
334
371
|
* emits `/js/...`), never outside either. A base or src on another origin is
|
|
335
372
|
* remote and not read.
|
|
336
373
|
*
|
|
374
|
+
* Missing scripts are keyed by the URL the browser resolves, so two spellings
|
|
375
|
+
* of one URL (`check	out.js`, `checkout.js`) are one entry, under the first
|
|
376
|
+
* spelling met. A path whose real path leaves the site root (a symlinked file
|
|
377
|
+
* or directory pointing elsewhere) is not read and is listed in
|
|
378
|
+
* `outside_site` by the link's own path. A page that ends inside a script
|
|
379
|
+
* element is listed in `unclosed`.
|
|
380
|
+
*
|
|
337
381
|
* @param {{ site_root: string, campaign_dir: string, pages: Array<{ page_id: string, built_path: string }> }} scope
|
|
338
382
|
* @param {string} targetRepo
|
|
339
383
|
*/
|
|
340
384
|
export function collectBuiltScriptSyntaxInputs(scope, targetRepo) {
|
|
341
385
|
const scripts = new Map();
|
|
342
386
|
const unresolved = new Map();
|
|
387
|
+
const outsideSite = new Map();
|
|
388
|
+
const unclosed = [];
|
|
343
389
|
const pages = Array.isArray(scope?.pages) ? scope.pages : [];
|
|
390
|
+
const siteRootReal = scope?.site_root ? realPathOf(scope.site_root) : null;
|
|
344
391
|
for (const page of pages) {
|
|
345
392
|
let html;
|
|
346
393
|
try {
|
|
@@ -348,9 +395,12 @@ export function collectBuiltScriptSyntaxInputs(scope, targetRepo) {
|
|
|
348
395
|
} catch {
|
|
349
396
|
continue;
|
|
350
397
|
}
|
|
351
|
-
const
|
|
398
|
+
const document = pageScriptDocument(html);
|
|
399
|
+
// One entry per page: the file ends inside at most one script, and the
|
|
400
|
+
// warning is about the page's truncated output.
|
|
401
|
+
if (document.unclosed.length) unclosed.push({ src: document.unclosed[0].src, pages: [page.page_id] });
|
|
352
402
|
const pageUrl = pageUrlFor(scope.site_root, page.built_path);
|
|
353
|
-
for (const ref of refs) {
|
|
403
|
+
for (const ref of document.refs) {
|
|
354
404
|
if (isRemote(ref.src)) continue;
|
|
355
405
|
const { remote, pathname } = resolveScriptUrl(ref.src, ref.base, pageUrl);
|
|
356
406
|
if (remote) continue;
|
|
@@ -366,9 +416,21 @@ export function collectBuiltScriptSyntaxInputs(scope, targetRepo) {
|
|
|
366
416
|
isFile = false;
|
|
367
417
|
}
|
|
368
418
|
if (!isFile) {
|
|
369
|
-
const
|
|
419
|
+
const urlKey = pathname ?? `\u0000${ref.src}`;
|
|
420
|
+
const entry = unresolved.get(urlKey) || { src: ref.src, pages: [] };
|
|
370
421
|
if (!entry.pages.includes(page.page_id)) entry.pages.push(page.page_id);
|
|
371
|
-
unresolved.set(
|
|
422
|
+
unresolved.set(urlKey, entry);
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
// Read where a static server would serve it, but only while the real
|
|
426
|
+
// path stays inside the site root. When either real path is unknown the
|
|
427
|
+
// check is undecidable, and the script is read as before.
|
|
428
|
+
const real = realPathOf(path);
|
|
429
|
+
if (siteRootReal && real && real !== siteRootReal && !real.startsWith(`${siteRootReal}${sep}`)) {
|
|
430
|
+
const file = relFrom(targetRepo, resolve(path));
|
|
431
|
+
const entry = outsideSite.get(file) || { file, src: ref.src, pages: [] };
|
|
432
|
+
if (!entry.pages.includes(page.page_id)) entry.pages.push(page.page_id);
|
|
433
|
+
outsideSite.set(file, entry);
|
|
372
434
|
continue;
|
|
373
435
|
}
|
|
374
436
|
const key = `${resolve(path)}\u0000${ref.module ? "module" : "script"}`;
|
|
@@ -386,7 +448,13 @@ export function collectBuiltScriptSyntaxInputs(scope, targetRepo) {
|
|
|
386
448
|
if (!entry.pages.includes(page.page_id)) entry.pages.push(page.page_id);
|
|
387
449
|
}
|
|
388
450
|
}
|
|
389
|
-
return {
|
|
451
|
+
return {
|
|
452
|
+
pages_scanned: pages.length,
|
|
453
|
+
scripts: [...scripts.values()],
|
|
454
|
+
unresolved: [...unresolved.values()],
|
|
455
|
+
outside_site: [...outsideSite.values()],
|
|
456
|
+
unclosed,
|
|
457
|
+
};
|
|
390
458
|
}
|
|
391
459
|
|
|
392
460
|
function gateBase(subject) {
|
|
@@ -405,11 +473,16 @@ function gateBase(subject) {
|
|
|
405
473
|
*
|
|
406
474
|
* @param {{ subject?: object, pages_scanned?: number,
|
|
407
475
|
* scripts?: Array<{ file: string, module?: boolean, content: string, pages?: string[] }>,
|
|
408
|
-
* unresolved?: Array<{ src: string, pages: string[] }
|
|
476
|
+
* unresolved?: Array<{ src: string, pages: string[] }>,
|
|
477
|
+
* outside_site?: Array<{ file: string, src: string, pages: string[] }>,
|
|
478
|
+
* unclosed?: Array<{ src: string | null, pages: string[] }> }} input
|
|
409
479
|
*/
|
|
410
|
-
export function evaluateBuiltScriptSyntax({ subject, pages_scanned: pagesScanned = 0, scripts = [], unresolved = [] } = {}) {
|
|
480
|
+
export function evaluateBuiltScriptSyntax({ subject, pages_scanned: pagesScanned = 0, scripts = [], unresolved = [], outside_site: outsideSite = [], unclosed = [] } = {}) {
|
|
411
481
|
const list = Array.isArray(scripts) ? scripts : [];
|
|
412
482
|
const missing = Array.isArray(unresolved) ? unresolved : [];
|
|
483
|
+
const outside = Array.isArray(outsideSite) ? outsideSite : [];
|
|
484
|
+
const open = Array.isArray(unclosed) ? unclosed : [];
|
|
485
|
+
const onPages = (pages) => (pages.length ? ` on ${pages.join(", ")}` : "");
|
|
413
486
|
// A local script the page loads that is not in the built output: a 404 at
|
|
414
487
|
// runtime. A warning, not a blocker (#502).
|
|
415
488
|
const warned = missing.map((entry) => {
|
|
@@ -418,11 +491,39 @@ export function evaluateBuiltScriptSyntax({ subject, pages_scanned: pagesScanned
|
|
|
418
491
|
code: SCRIPT_SYNTAX_MISSING_SCRIPT,
|
|
419
492
|
src: entry.src,
|
|
420
493
|
pages,
|
|
421
|
-
message: `${entry.src} is loaded by a local <script src>${pages
|
|
494
|
+
message: `${entry.src} is loaded by a local <script src>${onPages(pages)} but is not in the built output. The browser gets a 404 for it and nothing it would define runs. Add the file to the build, or remove the reference if the page does not need it.`,
|
|
422
495
|
};
|
|
423
496
|
});
|
|
424
|
-
|
|
425
|
-
|
|
497
|
+
// A script symlink whose target is outside the site root (#515): not read,
|
|
498
|
+
// and named by the link, never by where it points.
|
|
499
|
+
for (const entry of outside) {
|
|
500
|
+
const pages = Array.isArray(entry.pages) ? entry.pages : [];
|
|
501
|
+
warned.push({
|
|
502
|
+
code: SCRIPT_SYNTAX_SYMLINK_OUTSIDE_SITE,
|
|
503
|
+
file: entry.file,
|
|
504
|
+
src: entry.src,
|
|
505
|
+
pages,
|
|
506
|
+
message: `${entry.file} is loaded by a local <script src>${onPages(pages)} but is a symlink whose target is outside the site root, so its syntax was not checked. A static server may still serve it. Copy the script into the build output instead of linking to it.`,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
// A page that ends inside a script element (#515): the browser never runs
|
|
510
|
+
// that script, so it is not parsed. The page output is probably truncated.
|
|
511
|
+
for (const entry of open) {
|
|
512
|
+
const pages = Array.isArray(entry.pages) ? entry.pages : [];
|
|
513
|
+
warned.push({
|
|
514
|
+
code: SCRIPT_SYNTAX_UNCLOSED_SCRIPT,
|
|
515
|
+
src: entry.src ?? null,
|
|
516
|
+
pages,
|
|
517
|
+
message: `${entry.src ? `The <script src="${entry.src}">` : "An inline <script>"}${onPages(pages)} is never closed: the page ends before its </script>. The browser does not run a script element whose end tag never arrives, so it was not parsed. Check the page for truncated output and rebuild.`,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
const notes = [
|
|
521
|
+
missing.length ? ` ${missing.length} referenced local script(s) are not in the built output.` : "",
|
|
522
|
+
outside.length ? ` ${outside.length} script symlink(s) resolve outside the site root and were not read.` : "",
|
|
523
|
+
open.length ? ` ${open.length} page(s) end inside an unclosed <script>.` : "",
|
|
524
|
+
];
|
|
525
|
+
const missingNote = notes.join("");
|
|
526
|
+
const common = { scripts_unresolved: missing, scripts_outside_site: outside, warned, pages_scanned: pagesScanned };
|
|
426
527
|
if (list.length === 0) {
|
|
427
528
|
return {
|
|
428
529
|
...gateBase(subject),
|
package/src/cli.mjs
CHANGED
|
@@ -296,15 +296,18 @@ Usage:
|
|
|
296
296
|
campaigns-os start (--spec <json> | --map-id <id>) --source <html-dir> --target <page-kit-dir> --template-family <family>
|
|
297
297
|
[--brief <yaml|json>] [--proxy-base <url>] [--cached-spec] [--theme-policy <inspect_only|auto|off>]
|
|
298
298
|
[--wrapper-policy <strip_document_wrappers|preserve_document_wrappers|not_required|unknown>] [--design-manifest <path>]
|
|
299
|
-
[--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
|
|
299
|
+
[--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
|
|
300
|
+
[--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--no-run-session] [--force] # --force overwrites an assembly report that carries stage evidence (destructive; prints the cleared stage keys) and regenerates a stale Design Source Package an earlier intake synthesized and nobody changed
|
|
300
301
|
campaigns-os prepare-build (--spec <json> | --map-id <id>) --source <html-dir> --target <page-kit-dir> --template-family <family>
|
|
301
302
|
[--brief <yaml|json>] [--proxy-base <url>] [--cached-spec] [--theme-policy <inspect_only|auto|off>]
|
|
302
303
|
[--wrapper-policy <strip_document_wrappers|preserve_document_wrappers|not_required|unknown>] [--design-manifest <path>]
|
|
303
|
-
[--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
|
|
304
|
+
[--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
|
|
305
|
+
[--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--no-run-session] [--force]
|
|
304
306
|
campaigns-os build (--spec <json> | --map-id <id>) --source <html-dir> --target <page-kit-dir> --template-family <family>
|
|
305
307
|
[--brief <yaml|json>] [--proxy-base <url>] [--cached-spec] [--theme-policy <inspect_only|auto|off>]
|
|
306
308
|
[--wrapper-policy <strip_document_wrappers|preserve_document_wrappers|not_required|unknown>] [--design-manifest <path>]
|
|
307
|
-
[--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
|
|
309
|
+
[--allow-uncertified-template "<reason>"] [--order-path-depth <off|common|full>]
|
|
310
|
+
[--deploy-target <target>] [--preview-url <url>] [--production-url <url>] [--no-run-session] [--force] # intake alias for prepare-build + doctor
|
|
308
311
|
campaigns-os doctor --packet <campaign-runtime.build.json> [--context <json>] [--report <json>] [--strip-paths] [--write] [--no-write] [--doctor-out <path>] [--proxy-base <url>] [--no-live-refs] [--json] # inspection by default; --doctor-out requires --write; --no-write wins. When the packet's built _site/<route>/ exists and a public Campaigns API key resolves (packet, its local CampaignSpec, or the declared campaign-key env var), doctor makes one read-only GET of {proxy-base}/api/campaign under X-Campaign-Key to check each built page's shipping and package refs against the live campaign; --proxy-base overrides the canonical proxy (https, or a loopback host over http). --no-live-refs skips the read and records not_run with reason disabled. No key, no built page, or a failed read records derived.live_campaign_refs as not_run with its reason. Only doctor and qa run make this read; other commands that run doctor record not_read
|
|
309
312
|
campaigns-os doctor --built <page-kit-target-repo> --family <family> [--slug <slug>] [--base-url <url>] [--emit-packet [path]] [--json] # L7: doctor a built _site/ with no Build Packet
|
|
310
313
|
campaigns-os bundle check --packet <campaign-runtime.build.json> [--require-qa] [--json] # validate the canonical migration/readback JSON bundle; never substitutes markdown
|
package/src/content-residue.mjs
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
// Rendered-output content-residue scan + proof-attestation gate.
|
|
2
2
|
//
|
|
3
3
|
// Scans BUILT campaign output (_site HTML), not frontmatter: layout- or
|
|
4
|
-
// script-rendered
|
|
5
|
-
//
|
|
4
|
+
// script-rendered chrome only exists after the build, which is why
|
|
5
|
+
// frontmatter-level checks missed the hardcoded starter countdown chrome.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// in this public package.
|
|
7
|
+
// What it looks for is template residue, never the merchant's own copy: the
|
|
8
|
+
// public starter-template demo strings, bracket-style demo stubs, and the
|
|
9
|
+
// literal needs-merchant-input marker. Proof and urgency content the merchant
|
|
10
|
+
// supplies (reviews, ratings, "Verified Purchase" labels, stock counters,
|
|
11
|
+
// countdowns) is the merchant's responsibility and is not scanned. No
|
|
12
|
+
// merchant-specific fingerprints are carried in this public package.
|
|
14
13
|
//
|
|
15
|
-
// Posture (
|
|
16
|
-
// - hard: the literal needs-merchant-input marker, and
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
14
|
+
// Posture (two tiers):
|
|
15
|
+
// - hard: the literal needs-merchant-input marker, and starter countdown
|
|
16
|
+
// chrome on a brief-backed build whose brief does not verify the offer
|
|
17
|
+
// urgency — blockers (collect-inputs).
|
|
18
|
+
// - review: demo/placeholder residue — warnings; a hit means an unreplaced
|
|
19
|
+
// demo slot or a stale template.
|
|
21
20
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
22
21
|
import { join, relative } from "node:path";
|
|
23
22
|
|
|
@@ -55,63 +54,6 @@ export const DEMO_RESIDUE_TERMS = Object.freeze([
|
|
|
55
54
|
"Sell-Out Risk: High",
|
|
56
55
|
]);
|
|
57
56
|
|
|
58
|
-
// Generic anti-pattern classes (review tier). Each id is stable so the
|
|
59
|
-
// attestation/review UX can key on it.
|
|
60
|
-
export const CONTENT_ANTI_PATTERNS = Object.freeze([
|
|
61
|
-
{
|
|
62
|
-
id: "invented_counts",
|
|
63
|
-
antiPattern: 1,
|
|
64
|
-
rule: "Counts/ratings/percent-recommend claims require a real, brief-sourced basis.",
|
|
65
|
-
// Count branch requires a proof-scaled number (comma groups, 4+ digits, or
|
|
66
|
-
// a trailing +) so ordinary commerce quantities ("Choose 3 pairs and
|
|
67
|
-
// save") never enter the review queue; "pairs"/"sold" dropped as nouns
|
|
68
|
-
// for the same reason. Small invented counts are a knowingly accepted gap.
|
|
69
|
-
regex: /\b(?:\d{1,3}(?:,\d{3})+|\d{4,}|\d+\+)\s+(?:reviews?|ratings?|customers?|users?|famil(?:y|ies)|wearers?|people)\b|\b(?:4\.[5-9]|5\.0)\s*(?:\/\s*5|stars?)|\b(?:9[0-9]|100)%\b[^<]{0,60}\b(?:recommend|reported|said|would)\b/i,
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
id: "verified_buyer_chrome",
|
|
73
|
-
antiPattern: 2,
|
|
74
|
-
rule: "'Verified' labels must resolve to a real approved review source.",
|
|
75
|
-
regex: /Verified\s+(?:Buyer|Customer|Purchase)|What\s+(?:Our\s+)?Customers\s+(?:Think|Say)|Real\s+(?:People|Customers)[^<]{0,20}Real\s+(?:Results|Relief)|5[- ]Star\s+Review/i,
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
id: "byline_persona",
|
|
79
|
-
antiPattern: 3,
|
|
80
|
-
rule: "Advertorial identities must be real, brief-supplied, and authorized.",
|
|
81
|
-
regex: /Mom\s+of\s+Two|Consumer\s+Report|Review\s+Team|Wellness\s+Educator|Licensed\s+(?:Physiotherapist|Professional)/i,
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
id: "borrowed_authority",
|
|
85
|
-
antiPattern: 4,
|
|
86
|
-
rule: "Expert/clinician/institution references require name, credential, permission, and source.",
|
|
87
|
-
regex: /\bDr\.\s+[A-Z]|\bM\.?D\.?\b|doctor[- ]recommended|clinically\s+(?:recognized|recommended)|expert\s+(?:says|recommends)/i,
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
id: "press_marquee",
|
|
91
|
-
antiPattern: 8,
|
|
92
|
-
rule: "Press mentions require a brief-supplied working URL for the exact merchant and product.",
|
|
93
|
-
regex: /As\s+Seen\s+(?:On|In)|Featured\s+(?:On|In)/i,
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
id: "science_theater",
|
|
97
|
-
antiPattern: 9,
|
|
98
|
-
rule: "Study/clinical/certification claims require citation metadata and approved wording.",
|
|
99
|
-
regex: /peer[- ]reviewed|science[- ]backed|backed\s+by\s+science|stud(?:y|ies)\s+(?:show|prove|confirm)|researchers\s+found|clinically\s+(?:proven|shown|tested)|NASA[- ]developed/i,
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
id: "scarcity_theater",
|
|
103
|
-
antiPattern: 10,
|
|
104
|
-
rule: "Urgency renders only from a real, approved promotion window or live inventory source.",
|
|
105
|
-
regex: /ENDS\s+AT\s+MIDNIGHT|Offer\s+Expires|Deal\s+Ending|Only\s+\d+\s+(?:Units\s+)?Left|Stock\s+(?:Levels?\s+)?Low|\d+%\s+Sold|Sell[- ]?Out\s+Risk|supplies\s+are\s+limited/i,
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
id: "fake_comparison",
|
|
109
|
-
antiPattern: 11,
|
|
110
|
-
rule: "Tested-N/showdown framing requires a brief-supplied comparison matrix and test record.",
|
|
111
|
-
regex: /(?:we\s+)?tested\s+\d+\s+(?:contenders|products|devices|gloves|combinations|brands)|only\s+one\s+(?:survived|worked|stood)|Competitor\s+[12]\b/i,
|
|
112
|
-
},
|
|
113
|
-
]);
|
|
114
|
-
|
|
115
57
|
const ENTITIES = new Map([
|
|
116
58
|
["&", "&"], ["<", "<"], [">", ">"], [""", '"'],
|
|
117
59
|
["'", "'"], ["'", "'"], [" ", " "], ["’", "’"], ["—", "—"],
|
|
@@ -168,13 +110,12 @@ function excerptAt(html, index, span = 80) {
|
|
|
168
110
|
}
|
|
169
111
|
|
|
170
112
|
// Pure scan of one rendered HTML document. Returns { hard: [], review: [] };
|
|
171
|
-
// each finding: { id, tier, rule?, excerpt }.
|
|
172
|
-
// view (attributes count, comments
|
|
173
|
-
//
|
|
174
|
-
//
|
|
113
|
+
// each finding: { id, tier, rule?, excerpt }. The hard checks and the demo
|
|
114
|
+
// terms run on the markup view (attributes count, comments and script/style
|
|
115
|
+
// bodies do not), so attribute residue is still caught; bracket stubs run on
|
|
116
|
+
// the same view with tags stripped, so CSS attribute selectors never match.
|
|
175
117
|
export function scanRenderedHtml(html, { urgencyVerified = false } = {}) {
|
|
176
118
|
const markup = markupView(typeof html === "string" ? html : "");
|
|
177
|
-
const text = visibleText(typeof html === "string" ? html : "");
|
|
178
119
|
const hard = [];
|
|
179
120
|
const review = [];
|
|
180
121
|
|
|
@@ -205,19 +146,6 @@ export function scanRenderedHtml(html, { urgencyVerified = false } = {}) {
|
|
|
205
146
|
}
|
|
206
147
|
}
|
|
207
148
|
|
|
208
|
-
for (const pattern of CONTENT_ANTI_PATTERNS) {
|
|
209
|
-
if (pattern.id === "scarcity_theater" && urgencyVerified) continue;
|
|
210
|
-
const match = pattern.regex.exec(text);
|
|
211
|
-
if (match) {
|
|
212
|
-
review.push({
|
|
213
|
-
id: pattern.id,
|
|
214
|
-
tier: "review",
|
|
215
|
-
antiPattern: pattern.antiPattern,
|
|
216
|
-
rule: pattern.rule,
|
|
217
|
-
excerpt: excerptAt(text, match.index),
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
149
|
return { hard, review };
|
|
222
150
|
}
|
|
223
151
|
|
package/src/diagnostic.mjs
CHANGED
|
@@ -20,7 +20,8 @@ const REASONS = new Set([
|
|
|
20
20
|
"built_output.upsell_selector_scope", "built_output.sdk_markup.swap_with_add_to_cart",
|
|
21
21
|
"built_output.sdk_markup.checkout_not_form", "built_output.sdk_markup.wrong_field_name",
|
|
22
22
|
"built_output.sdk_markup.missing_selector_id_match", "built_output.script_syntax.parse_failure",
|
|
23
|
-
"built_output.script_syntax.missing_script", "
|
|
23
|
+
"built_output.script_syntax.missing_script", "built_output.script_syntax.unclosed_script",
|
|
24
|
+
"built_output.script_syntax.symlink_outside_site", "source_html.producer_provenance",
|
|
24
25
|
"source_html.producer_provenance.source_type", "source_html.producer_provenance.screenshot_fallback_used",
|
|
25
26
|
"source_html.producer_provenance.semantic_section_count", "source_html.producer_provenance.material_fingerprint",
|
|
26
27
|
"source_html.producer_provenance.section_exports", "source_html.producer_provenance.waiver_inert",
|
package/src/doctor/checks.mjs
CHANGED
|
@@ -363,7 +363,7 @@ const SPEC_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
363
363
|
{
|
|
364
364
|
id: PAGE_KIT_SDK_VERSION_SCOPE,
|
|
365
365
|
phase: "target",
|
|
366
|
-
run: ({ spec, errors, warnings, ready, derived, buildState }) => validateTargetSdkVersion(spec, errors, warnings, ready, derived, buildState),
|
|
366
|
+
run: ({ spec, packet, errors, warnings, ready, derived, buildState }) => validateTargetSdkVersion(spec, errors, warnings, ready, derived, buildState, packet),
|
|
367
367
|
},
|
|
368
368
|
{
|
|
369
369
|
id: PAGE_KIT_STORE_PROFILE_SCOPE,
|
|
@@ -1159,17 +1159,22 @@ function validateLocalProof(packet, report, errors, warnings, ready) {
|
|
|
1159
1159
|
addIssue(errors, LOCAL_PROOF_PARITY_SCOPE, `Production parity FAILED${difference ? ` — first non-gated difference: ${singleLineField(String(difference.kind))} at ${singleLineField(String(difference.route))}${difference.line ? ` line ${difference.line}` : ""}: ${singleLineField(String(difference.detail || ""))}` : `: ${singleLineField(String(parity.summary || ""))}`}. Rebuild in development, re-prove, and run ${parityCommand} again. ${LOCAL_PROOF_NEVER_EDIT_RULE}`, difference ? { first_difference: difference } : undefined);
|
|
1160
1160
|
}
|
|
1161
1161
|
|
|
1162
|
-
function validateTargetSdkVersion(spec, errors, warnings, ready, derived, buildState) {
|
|
1162
|
+
function validateTargetSdkVersion(spec, errors, warnings, ready, derived, buildState, packet = null) {
|
|
1163
1163
|
const report = buildState?.report || null;
|
|
1164
1164
|
const required = stageIsTerminal(report?.stages?.setup?.status)
|
|
1165
1165
|
|| stageIsTerminal(report?.stages?.assembly?.status)
|
|
1166
1166
|
|| derived.scaffold_required !== true;
|
|
1167
|
+
const family = packet?.assembly?.template_family;
|
|
1168
|
+
const verified = isNonEmptyString(family) && isCertifiedTemplateFamily(family)
|
|
1169
|
+
? assessTemplateFreshness({ family, catalog: resolveCommerceCatalog(), sdkSupportPolicy: defaultSdkSupportPolicy() }).verified_sdk_version
|
|
1170
|
+
: null;
|
|
1167
1171
|
const gate = evaluatePageKitSdkVersion({
|
|
1168
1172
|
spec,
|
|
1169
1173
|
specStatus: buildState?.specStatus || "ok",
|
|
1170
1174
|
targetLoad: buildState?.pageKitCampaignConfig,
|
|
1171
1175
|
waivers: report?.waivers,
|
|
1172
1176
|
required,
|
|
1177
|
+
familyVerification: verified ? { family, sdk_version: verified } : null,
|
|
1173
1178
|
});
|
|
1174
1179
|
derived.checkpoint_gates.push(gate);
|
|
1175
1180
|
|
|
@@ -2041,7 +2046,6 @@ function collectBuiltPageIdentityInputs(scope, targetRepo) {
|
|
|
2041
2046
|
}
|
|
2042
2047
|
return {
|
|
2043
2048
|
page_id: page.page_id,
|
|
2044
|
-
page_type: page.page_type,
|
|
2045
2049
|
route: page.route,
|
|
2046
2050
|
file: relFromDir(targetRepo, page.built_path),
|
|
2047
2051
|
content,
|
|
@@ -3200,7 +3204,7 @@ function validateSourceCoverage(packet, packetPath, spec, errors, warnings, read
|
|
|
3200
3204
|
addIssue(
|
|
3201
3205
|
warnings,
|
|
3202
3206
|
"source_html.pages.source_hash",
|
|
3203
|
-
`Source page "${page.page_id}" hash mismatch — file at ${page.path} has changed since the
|
|
3207
|
+
`Source page "${page.page_id}" hash mismatch — file at ${page.path} has changed since intake recorded it in the Build Packet (recorded sha256=${expectedHash.slice(0, 12)}…, on-disk sha256=${actualHash.slice(0, 12)}…). This warning does not block. Re-running start or prepare-build with --force records the current file and clears recorded stage evidence; editing the source-html manifest alone does not change the hash the packet holds.`,
|
|
3204
3208
|
);
|
|
3205
3209
|
}
|
|
3206
3210
|
}
|
|
@@ -4019,11 +4023,11 @@ function collectPlaceholderTextResidueMatches(root, terms) {
|
|
|
4019
4023
|
}
|
|
4020
4024
|
|
|
4021
4025
|
// Rendered-output content-residue scan (assembly-surfaces prototype): scans
|
|
4022
|
-
// BUILT pages for the needs-merchant-input marker and
|
|
4023
|
-
//
|
|
4024
|
-
//
|
|
4025
|
-
//
|
|
4026
|
-
// only exists after the build.
|
|
4026
|
+
// BUILT pages for the needs-merchant-input marker and, on a brief-backed
|
|
4027
|
+
// build, starter countdown chrome the brief does not verify (blockers), plus
|
|
4028
|
+
// template demo/placeholder residue (warnings). The merchant's own proof and
|
|
4029
|
+
// urgency copy is not scanned. Scans _site output, not frontmatter —
|
|
4030
|
+
// layout/script-rendered chrome only exists after the build.
|
|
4027
4031
|
export function validateBuiltContentResidue(packet, errors, warnings, ready, derived, buildState = {}) {
|
|
4028
4032
|
if (!isStageComplete(buildState.report, "assembly")) return;
|
|
4029
4033
|
// Scan the RENDERED _site output, not the campaign source dir: the Liquid
|
|
@@ -4036,7 +4040,7 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
|
|
|
4036
4040
|
const urgencyVerified = briefUrgencyVerified(brief?.payload);
|
|
4037
4041
|
const findings = scanBuiltOutputContentResidue(siteRoot, { urgencyVerified });
|
|
4038
4042
|
if (!findings.length) {
|
|
4039
|
-
ready.push("Built output carries no needs-input markers, unverified urgency chrome, or
|
|
4043
|
+
ready.push("Built output carries no needs-input markers, unverified urgency chrome, or template demo residue");
|
|
4040
4044
|
return;
|
|
4041
4045
|
}
|
|
4042
4046
|
// One issue per finding id, carrying the FULL file inventory: the id keeps
|
|
@@ -4069,9 +4073,8 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
|
|
|
4069
4073
|
);
|
|
4070
4074
|
} else if (id === "unverified_urgency_countdown") {
|
|
4071
4075
|
// The scanner emits this only when the brief does not verify urgency
|
|
4072
|
-
// (urgencyVerified=false).
|
|
4073
|
-
//
|
|
4074
|
-
// with no brief payload may carry an intentional countdown — warning.
|
|
4076
|
+
// (urgencyVerified=false). Only a readable brief payload (AI-assembled)
|
|
4077
|
+
// makes it a finding.
|
|
4075
4078
|
if (briefReadable) {
|
|
4076
4079
|
addIssue(
|
|
4077
4080
|
errors,
|
|
@@ -4079,30 +4082,18 @@ export function validateBuiltContentResidue(packet, errors, warnings, ready, der
|
|
|
4079
4082
|
`Built output renders countdown chrome without verified offer urgency (${where}). Set offer.urgency.verified in ${BRIEF_PAYLOAD_REL_PATH} from a real promotion window, or blank the urgency slots.`,
|
|
4080
4083
|
);
|
|
4081
4084
|
} else if (brief?.error) {
|
|
4082
|
-
//
|
|
4083
|
-
// proof_attestation.unreadable
|
|
4084
|
-
//
|
|
4085
|
-
// brief parses — "confirm the urgency is real" copy here would point
|
|
4086
|
-
// the operator at the wrong fix.
|
|
4087
|
-
} else {
|
|
4088
|
-
addIssue(
|
|
4089
|
-
warnings,
|
|
4090
|
-
"content_residue.urgency_unattested",
|
|
4091
|
-
`Built output renders countdown chrome and no brief payload attests the promotion window (${where}). Confirm the urgency is real (a genuine offer window or live inventory) before launch.`,
|
|
4092
|
-
);
|
|
4085
|
+
// Deliberately nothing: an unreadable brief payload already blocks
|
|
4086
|
+
// through proof_attestation.unreadable (validateProofAttestation),
|
|
4087
|
+
// and urgency is re-evaluated once the brief parses.
|
|
4093
4088
|
}
|
|
4089
|
+
// No brief payload at all: the countdown is the merchant's content and
|
|
4090
|
+
// is not ours to question.
|
|
4094
4091
|
} else if (id === "demo_residue_term" || id === "bracket_placeholder_stub") {
|
|
4095
4092
|
addIssue(
|
|
4096
4093
|
warnings,
|
|
4097
4094
|
"content_residue.demo_residue",
|
|
4098
4095
|
`Built output carries template demo/placeholder residue (${where}; e.g. "${finding.excerpt}"). Fill or blank the slot — demo values must never ship.`,
|
|
4099
4096
|
);
|
|
4100
|
-
} else {
|
|
4101
|
-
addIssue(
|
|
4102
|
-
warnings,
|
|
4103
|
-
"content_residue.anti_pattern",
|
|
4104
|
-
`Built output matches content anti-pattern "${id}" (${where}; e.g. "${finding.excerpt}"). ${finding.rule || "Remove it or route it through brief-sourced proof."} This is a review warning and nothing downstream blocks on it: the claim is the operator's and the client's responsibility — remove or evidence it, never make it more plausible.`,
|
|
4105
|
-
);
|
|
4106
4097
|
}
|
|
4107
4098
|
}
|
|
4108
4099
|
}
|