@nextcommerce/campaigns-os 1.52.0 → 1.54.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 +109 -0
- package/README.md +1 -0
- package/agents/claude/CLAUDE.md +2 -0
- package/agents/codex/AGENTS.md +2 -0
- package/agents/copilot/copilot-instructions.md +2 -0
- package/agents/cursor/campaigns-os.mdc +2 -0
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -27
- package/contracts/effects.v1.json +295 -47
- package/contracts/release-ledger.json +660 -0
- package/contracts/supported-surface.json +3 -3
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/brand-theme-bridge.md +2 -0
- package/docs/build-packet.md +67 -19
- package/docs/campaign-build-brief.md +48 -17
- package/docs/campaign-standardization-report.md +32 -0
- package/docs/design-source-package.md +7 -3
- package/docs/effects.md +10 -1
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +2 -0
- package/docs/progress-snapshots.md +5 -5
- package/docs/qa-and-test-orders.md +30 -3
- package/docs/readback.md +19 -2
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +15 -0
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -0
- package/package.json +1 -1
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -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 +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +5 -3
- package/skills/next-campaigns-os/SKILL.md +10 -19
- package/skills/next-campaigns-os/references/session-intake.md +10 -6
- package/skills/next-campaigns-os-setup/SKILL.md +4 -3
- package/skills/next-campaigns-polish/SKILL.md +7 -5
- package/skills/next-campaigns-qa/SKILL.md +5 -3
- package/skills.json +10 -10
- package/src/brand-theme.mjs +21 -20
- package/src/brief-summary.mjs +275 -0
- package/src/build-brief.mjs +167 -15
- package/src/campaign-ecosystem.mjs +153 -31
- package/src/cli.mjs +182 -115
- package/src/contrast.mjs +649 -0
- package/src/design-source-publication.mjs +110 -3
- package/src/directory-lock.mjs +79 -7
- package/src/doctor/checks.mjs +141 -16
- package/src/doctor/inspect.mjs +13 -0
- package/src/doctor/next-step.mjs +15 -6
- package/src/input-currency.mjs +394 -0
- package/src/input-refresh.mjs +708 -0
- package/src/invocation.mjs +6 -2
- package/src/polish-browser.mjs +503 -201
- package/src/polish-deadline.mjs +7 -0
- package/src/polish-node.mjs +326 -46
- package/src/polish-readability.mjs +828 -0
- package/src/progress-node.mjs +18 -5
- package/src/qa-browser.mjs +211 -93
- package/src/qa-node.mjs +47 -7
- package/src/qc-accept.mjs +44 -8
- package/src/qc-check-registry.mjs +15 -7
- package/src/qc-results.mjs +326 -34
- package/src/readback.mjs +58 -10
- package/src/sdk-attribute-index.mjs +111 -23
- package/src/sdk-markup.mjs +49 -10
- package/src/sdk-repin.mjs +282 -0
- package/src/stage-ledger.mjs +235 -44
- package/src/stage-record.mjs +154 -30
- package/src/standardization-report.mjs +26 -1
- package/src/target-lock.mjs +9 -1
- package/src/tooling-setup.mjs +1 -1
|
@@ -7,7 +7,11 @@ import { writeThemeArtifacts } from "./brand-theme.mjs";
|
|
|
7
7
|
import { cloneJson, filesystemPathsMatch, isObject, isNonEmptyString, optionalString, readJson, resolveFromFile, sha256File } from "./cli-helpers.mjs";
|
|
8
8
|
import { DESIGN_SOURCE_PACKAGE_REL_PATH, createDesignSourcePackageArtifactReference, hashSerializedDesignSourcePackage, serializeDesignSourcePackage, synthesizeHtmlFunnelDesignSourcePackage, validateDesignSourcePackage } from "./design-source-package.mjs";
|
|
9
9
|
import { stampDoctorProducer } from "./doctor-sidecar.mjs";
|
|
10
|
+
import { stageWriteInputs } from "./input-currency.mjs";
|
|
11
|
+
import { cmd } from "./install-invocation.mjs";
|
|
10
12
|
import { ASSEMBLY_REPORT_STAGE_KEYS } from "./orchestration-stage-contract.mjs";
|
|
13
|
+
import { HOST_STRIPPED_CODE } from "./source-html-intake.mjs";
|
|
14
|
+
import { archiveForForceReset, stageHistoryPresent } from "./stage-ledger.mjs";
|
|
11
15
|
import { targetLockPath, withTargetLock } from "./target-lock.mjs";
|
|
12
16
|
import { resolveTemplateFamilyDesignSource } from "./template-reference.mjs";
|
|
13
17
|
|
|
@@ -71,12 +75,93 @@ function guardAssemblyReportOverwrite(reportPath, args, { announced = null } = {
|
|
|
71
75
|
const unannounced = announced ? stageKeys.filter((key) => !announced.includes(key)) : stageKeys;
|
|
72
76
|
if (unannounced.length > 0) {
|
|
73
77
|
console.warn(
|
|
74
|
-
`[campaigns-os prepare-build] --force: overwriting assembly report at ${reportPath}; clearing stage evidence for: ${unannounced.join(", ")}.`,
|
|
78
|
+
`[campaigns-os prepare-build] --force: overwriting assembly report at ${reportPath}; clearing stage evidence for: ${unannounced.join(", ")}. Completed build, Polish and QA records are archived into their stage history, which is kept.`,
|
|
75
79
|
);
|
|
76
80
|
}
|
|
77
81
|
return stageKeys;
|
|
78
82
|
}
|
|
79
83
|
|
|
84
|
+
// Operator state on the report an intake would replace, one line per item:
|
|
85
|
+
// recorded waivers and warning accepts, stage history, an applied theme, and
|
|
86
|
+
// report evidence other than intake's own host-strip lines.
|
|
87
|
+
function assemblyReportOperatorState(existingReport) {
|
|
88
|
+
if (!isObject(existingReport)) return [];
|
|
89
|
+
const items = [];
|
|
90
|
+
const count = (value) => (Array.isArray(value) ? value.length : 0);
|
|
91
|
+
if (count(existingReport.waivers)) items.push(`waivers (${count(existingReport.waivers)} recorded waiver${count(existingReport.waivers) === 1 ? "" : "s"})`);
|
|
92
|
+
if (count(existingReport.qc_accepts)) items.push(`qc_accepts (${count(existingReport.qc_accepts)} recorded warning accept${count(existingReport.qc_accepts) === 1 ? "" : "s"})`);
|
|
93
|
+
if (stageHistoryPresent(existingReport)) items.push("stage history (superseded build, Polish or QA records)");
|
|
94
|
+
if (existingReport.theme?.status === "applied") items.push("theme (status applied)");
|
|
95
|
+
const evidence = Array.isArray(existingReport.evidence) ? existingReport.evidence.filter((entry) => !(isObject(entry) && entry.code === HOST_STRIPPED_CODE)) : [];
|
|
96
|
+
if (evidence.length) items.push(`evidence (${evidence.length} recorded line${evidence.length === 1 ? "" : "s"})`);
|
|
97
|
+
return items;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Packet settings recorded since the last intake that this run would
|
|
101
|
+
// overwrite (every setting `qa policy set` records): each named with the
|
|
102
|
+
// value on disk and the value this run writes.
|
|
103
|
+
const INTAKE_PACKET_SETTINGS = Object.freeze([
|
|
104
|
+
["deploy.target", (packet) => packet?.deploy?.target],
|
|
105
|
+
["deploy.preview_url", (packet) => packet?.deploy?.preview_url],
|
|
106
|
+
["deploy.production_url", (packet) => packet?.deploy?.production_url],
|
|
107
|
+
["qa.proof_policy.order_path_depth", (packet) => packet?.qa?.proof_policy?.order_path_depth],
|
|
108
|
+
["campaign.allowed_domains_confirmed", (packet) => packet?.campaign?.allowed_domains_confirmed],
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
function packetSettingChanges(packetPath, planned) {
|
|
112
|
+
if (!isObject(planned) || !existsSync(packetPath)) return [];
|
|
113
|
+
let existing;
|
|
114
|
+
try {
|
|
115
|
+
existing = readJson(packetPath);
|
|
116
|
+
} catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
const items = [];
|
|
120
|
+
for (const [field, read] of INTAKE_PACKET_SETTINGS) {
|
|
121
|
+
const onDisk = read(existing) ?? null;
|
|
122
|
+
const written = read(planned) ?? null;
|
|
123
|
+
if (onDisk !== written) items.push(`${field} (recorded ${JSON.stringify(onDisk)}; this run would write ${JSON.stringify(written)})`);
|
|
124
|
+
}
|
|
125
|
+
return items;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Intake replaces the report and packet whole, so recorded operator state
|
|
129
|
+
// would be lost silently. Without --force it refuses and names each item;
|
|
130
|
+
// with --force it names each item as cleared (once).
|
|
131
|
+
function guardIntakeOperatorState(reportPath, packetPath, { force, planned = null, announced = [] }) {
|
|
132
|
+
let existingReport = null;
|
|
133
|
+
try {
|
|
134
|
+
existingReport = existsSync(reportPath) ? readJson(reportPath) : null;
|
|
135
|
+
} catch {
|
|
136
|
+
existingReport = null;
|
|
137
|
+
}
|
|
138
|
+
const items = [...assemblyReportOperatorState(existingReport), ...packetSettingChanges(packetPath, planned)];
|
|
139
|
+
if (!items.length) return announced;
|
|
140
|
+
if (force !== true) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`Rerunning prepare-build/start/build would discard recorded operator state: ${items.join("; ")}. `
|
|
143
|
+
+ `To save brief answers or a CampaignSpec change without discarding anything, run ${cmd("record")} brief or ${cmd("record")} spec instead. `
|
|
144
|
+
+ "To rerun intake anyway, pass the original deploy and order-path flags, or --force to clear the listed state (destructive; completed build, Polish and QA records are archived into stage history). "
|
|
145
|
+
+ `After --force, record a cleared packet setting again with ${cmd("qa")} policy set.`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const unannounced = items.filter((item) => !announced.includes(item));
|
|
149
|
+
if (unannounced.length) {
|
|
150
|
+
console.warn(`[campaigns-os prepare-build] --force: clearing recorded operator state: ${unannounced.join("; ")}.`);
|
|
151
|
+
}
|
|
152
|
+
return [...announced, ...unannounced];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The report this run replaces, read for the --force archive; null when there
|
|
156
|
+
// is none or it cannot be read.
|
|
157
|
+
function readReplacedReport(reportPath) {
|
|
158
|
+
try {
|
|
159
|
+
return existsSync(reportPath) ? readJson(reportPath) : null;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
80
165
|
function artifactRelativePath(artifactPath, targetPath) {
|
|
81
166
|
const rel = relative(dirname(resolve(artifactPath)), resolve(targetPath)).replaceAll("\\", "/");
|
|
82
167
|
return rel || ".";
|
|
@@ -806,12 +891,20 @@ export async function withDesignSourcePublication({ targetRepo, outputs, force =
|
|
|
806
891
|
// fetched, and a run waiting here cannot replace it meanwhile.
|
|
807
892
|
publishSpec?.();
|
|
808
893
|
let announcedStageEvidence = guardAssemblyReportOverwrite(reportPath, { force });
|
|
894
|
+
// Operator state is checked once the run knows the packet settings it
|
|
895
|
+
// would write, before the package.
|
|
896
|
+
let plannedPacketSettings = null;
|
|
897
|
+
let announcedOperatorState = null;
|
|
898
|
+
const checkOperatorState = () => {
|
|
899
|
+
announcedOperatorState = guardIntakeOperatorState(reportPath, packetPath, { force, planned: plannedPacketSettings, announced: announcedOperatorState || [] });
|
|
900
|
+
};
|
|
809
901
|
// Stage producers commit the report without this lock, so evidence can land
|
|
810
902
|
// while the run works. Re-check before each write that is not rolled back:
|
|
811
903
|
// the theme artifacts, then the JSON outputs. --force names a stage once.
|
|
812
904
|
const recheckStageEvidence = () => {
|
|
813
905
|
const found = guardAssemblyReportOverwrite(reportPath, { force }, { announced: announcedStageEvidence });
|
|
814
906
|
announcedStageEvidence = [...new Set([...announcedStageEvidence, ...found])];
|
|
907
|
+
checkOperatorState();
|
|
815
908
|
};
|
|
816
909
|
let completed = 0;
|
|
817
910
|
const enter = (index, { once = false } = {}) => {
|
|
@@ -821,8 +914,13 @@ export async function withDesignSourcePublication({ targetRepo, outputs, force =
|
|
|
821
914
|
let designSourcePackage = null;
|
|
822
915
|
return fn(Object.freeze({
|
|
823
916
|
paths,
|
|
824
|
-
|
|
917
|
+
// `plannedPacket`: the part of the packet this run would write that
|
|
918
|
+
// carries the settings INTAKE_PACKET_SETTINGS names, checked against
|
|
919
|
+
// the packet on disk.
|
|
920
|
+
package({ plannedPacket = null, ...inputs }) {
|
|
825
921
|
enter(0, { once: true });
|
|
922
|
+
plannedPacketSettings = plannedPacket;
|
|
923
|
+
checkOperatorState();
|
|
826
924
|
// Reject deterministic destination failures before publishing the fixed
|
|
827
925
|
// DSP. The DSP intentionally precedes the theme and JSON artifacts that
|
|
828
926
|
// reference it; a later I/O race may therefore leave a valid DSP for a
|
|
@@ -858,8 +956,17 @@ export async function withDesignSourcePublication({ targetRepo, outputs, force =
|
|
|
858
956
|
completed = Math.max(completed, 2);
|
|
859
957
|
return written;
|
|
860
958
|
},
|
|
861
|
-
publish({ packet, brief, context, report }) {
|
|
959
|
+
publish({ packet, brief, context, report: reseeded }) {
|
|
862
960
|
enter(2, { once: true });
|
|
961
|
+
// The records this run replaces stay visible: completed build, Polish
|
|
962
|
+
// and QA records go to their stage history (only --force reaches here
|
|
963
|
+
// with any), and each stage's history and input change carry over.
|
|
964
|
+
const now = new Date().toISOString();
|
|
965
|
+
const bound = { briefMaterial: reseeded?.build_brief?.material ?? null, specMaterial: reseeded?.identity?.spec_material_hash ?? null };
|
|
966
|
+
const report = archiveForForceReset(readReplacedReport(reportPath), reseeded, {
|
|
967
|
+
now,
|
|
968
|
+
detectChange: (key, record) => stageWriteInputs(key, bound).detectChange(record),
|
|
969
|
+
});
|
|
863
970
|
// Before the report goes out, narrow the pending record to exactly what the
|
|
864
971
|
// report will say: the package's own hash when it is synthesized, nothing
|
|
865
972
|
// when it is adopted. A candidate this run or an earlier failed one never
|
package/src/directory-lock.mjs
CHANGED
|
@@ -18,7 +18,13 @@
|
|
|
18
18
|
// to an older writer that may still be alive between its mkdir and its owner
|
|
19
19
|
// write (#501). A waiter refuses it after a short grace, leaving it for the
|
|
20
20
|
// documented offline procedure. (See publishStagedDirectory for the one
|
|
21
|
-
// mixed-version race this cannot close, tracked in #514.)
|
|
21
|
+
// mixed-version race this cannot close, tracked in #514.) Anything else at the
|
|
22
|
+
// lock path, a file or a directory holding other entries, is not a lock at
|
|
23
|
+
// all: it is refused by name and never removed (#514).
|
|
24
|
+
//
|
|
25
|
+
// Staging and released siblings a dead process left beside the lock are swept
|
|
26
|
+
// by the next holder, under the lock; a sibling whose owner is alive or
|
|
27
|
+
// unreadable is left alone (#514).
|
|
22
28
|
//
|
|
23
29
|
// The lock is reentrant for its holder: code running inside `fn` (in the
|
|
24
30
|
// same async context) that asks for the same lock enters directly instead of
|
|
@@ -26,7 +32,7 @@
|
|
|
26
32
|
// stage writers that take it too.
|
|
27
33
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
28
34
|
import { randomBytes } from "node:crypto";
|
|
29
|
-
import { lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
30
36
|
import { basename, dirname, join, resolve } from "node:path";
|
|
31
37
|
|
|
32
38
|
const heldLocks = new AsyncLocalStorage();
|
|
@@ -39,6 +45,47 @@ const exists = (path) => {
|
|
|
39
45
|
try { lstatSync(path); return true; } catch { return false; }
|
|
40
46
|
};
|
|
41
47
|
|
|
48
|
+
// True only when the owner record names a process that no longer exists. An
|
|
49
|
+
// unreadable record, or a pid this process may not signal, is not dead.
|
|
50
|
+
const ownerIsDead = (owner) => {
|
|
51
|
+
if (!(Number.isInteger(owner?.pid) && owner.pid > 0 && typeof owner.token === "string")) return false;
|
|
52
|
+
try { process.kill(owner.pid, 0); return false; } catch (error) { return error.code === "ESRCH"; }
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
56
|
+
|
|
57
|
+
// The siblings this module creates beside a lock and normally removes itself:
|
|
58
|
+
// a crash leaves staging and recovery-staging ones, a release whose tomb could
|
|
59
|
+
// not be put back leaves a released one. Each carries an owner.json.
|
|
60
|
+
function siblingPattern(path) {
|
|
61
|
+
return new RegExp(`^${escapeRegExp(basename(path))}\\.(?:staging|recovery-staging|released)-[0-9a-f]{32}$`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Called only by the holder of the lock, so no two sweeps race, and a
|
|
65
|
+
// sibling is removed only when its owner is a dead process: a live owner may
|
|
66
|
+
// still be staging or releasing, and an unreadable one may be between its
|
|
67
|
+
// mkdir and its owner write. Best effort; a failure leaves the sibling.
|
|
68
|
+
function sweepDeadSiblings(path) {
|
|
69
|
+
const parent = dirname(path);
|
|
70
|
+
const pattern = siblingPattern(path);
|
|
71
|
+
let entries;
|
|
72
|
+
try { entries = readdirSync(parent); } catch { return; }
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (!pattern.test(entry)) continue;
|
|
75
|
+
const sibling = join(parent, entry);
|
|
76
|
+
try {
|
|
77
|
+
const stat = lstatSync(sibling);
|
|
78
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
|
|
79
|
+
if (!ownerIsDead(readOwner(join(sibling, "owner.json")))) continue;
|
|
80
|
+
rmSync(sibling, { recursive: true, force: true });
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// What a lock directory may hold besides its owner record: the recovery
|
|
86
|
+
// claim. A directory without owner.json that holds anything else is not a lock.
|
|
87
|
+
const LOCK_ENTRIES = new Set(["owner.json", ".recovery"]);
|
|
88
|
+
|
|
42
89
|
// The reentrancy key names the lock directory through its real parent, so a
|
|
43
90
|
// holder that reached the target through a symlink still recognizes itself.
|
|
44
91
|
function lockKey(path) {
|
|
@@ -106,9 +153,7 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
|
|
|
106
153
|
try {
|
|
107
154
|
const stat = lstatSync(path);
|
|
108
155
|
if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
|
|
109
|
-
|
|
110
|
-
if (!(Number.isInteger(current?.pid) && current.pid > 0 && typeof current.token === "string")) return false;
|
|
111
|
-
try { process.kill(current.pid, 0); return false; } catch (error) { return error.code === "ESRCH"; }
|
|
156
|
+
return ownerIsDead(readOwner(ownerPath));
|
|
112
157
|
} catch {
|
|
113
158
|
return false;
|
|
114
159
|
}
|
|
@@ -179,7 +224,32 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
|
|
|
179
224
|
return current - start >= budgetMs;
|
|
180
225
|
};
|
|
181
226
|
const fail = (error) => unavailable(error);
|
|
182
|
-
|
|
227
|
+
// Names what occupies the lock path when it is not a lock, else null. A
|
|
228
|
+
// file there, or a directory with no owner record that holds entries a lock
|
|
229
|
+
// never has, will not clear by waiting and is never removed here.
|
|
230
|
+
const obstruction = () => {
|
|
231
|
+
let stat;
|
|
232
|
+
try { stat = lstatSync(path); } catch { return null; }
|
|
233
|
+
if (stat.isSymbolicLink()) return "a symbolic link, not a lock";
|
|
234
|
+
if (!stat.isDirectory()) return stat.isFile() ? "a regular file, not a lock" : "a special file, not a lock";
|
|
235
|
+
if (exists(ownerPath)) return null;
|
|
236
|
+
let foreign;
|
|
237
|
+
try { foreign = readdirSync(path).filter((entry) => !LOCK_ENTRIES.has(entry)).sort(); } catch { return null; }
|
|
238
|
+
if (foreign.length === 0) return null;
|
|
239
|
+
const shown = foreign.slice(0, 3).join(", ") + (foreign.length > 3 ? `, and ${foreign.length - 3} more` : "");
|
|
240
|
+
return `a directory that is not a lock (no owner.json; it holds ${shown})`;
|
|
241
|
+
};
|
|
242
|
+
// The refusal once waiting ends, telling a held lock, an ownerless lock and
|
|
243
|
+
// a non-lock obstruction apart.
|
|
244
|
+
const contended = () => {
|
|
245
|
+
const found = obstruction();
|
|
246
|
+
if (found) {
|
|
247
|
+
return Object.assign(new Error(`Lock path is occupied by ${found}: ${path}`), { code: "ENOTLOCK", obstruction: found });
|
|
248
|
+
}
|
|
249
|
+
if (ownerless()) return Object.assign(new Error(`Lock has no owner record: ${path}`), { code: "EEXIST" });
|
|
250
|
+
return Object.assign(new Error(`Lock is held: ${path}`), { code: "EEXIST" });
|
|
251
|
+
};
|
|
252
|
+
const sweep = () => sweepDeadSiblings(path);
|
|
183
253
|
|
|
184
254
|
const release = () => {
|
|
185
255
|
if (readOwner(ownerPath)?.token !== token) return;
|
|
@@ -193,7 +263,7 @@ function createLock(path, { budgetMs, unavailable, now = Date.now, ownerlessGrac
|
|
|
193
263
|
}
|
|
194
264
|
};
|
|
195
265
|
|
|
196
|
-
return { token, stage, publish, recover, heldBySelfProcess, expired, fail, contended, release };
|
|
266
|
+
return { token, stage, publish, recover, heldBySelfProcess, expired, fail, contended, sweep, release };
|
|
197
267
|
}
|
|
198
268
|
|
|
199
269
|
function reentrantKey(path) {
|
|
@@ -232,6 +302,7 @@ export async function withDirectoryLock(path, fn, options) {
|
|
|
232
302
|
await sleep(20);
|
|
233
303
|
}
|
|
234
304
|
try {
|
|
305
|
+
lock.sweep();
|
|
235
306
|
return await runHolding(key, lock.token, fn);
|
|
236
307
|
} finally {
|
|
237
308
|
lock.release();
|
|
@@ -263,6 +334,7 @@ export function withDirectoryLockSync(path, fn, options) {
|
|
|
263
334
|
(options.sleep ?? sleepSync)(20);
|
|
264
335
|
}
|
|
265
336
|
try {
|
|
337
|
+
lock.sweep();
|
|
266
338
|
return runHolding(key, lock.token, fn);
|
|
267
339
|
} finally {
|
|
268
340
|
lock.release();
|
package/src/doctor/checks.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Doctor checks: the check registries, validatePacket and the validators they run.
|
|
2
2
|
import { campaignSpecIdentity, resolveCampaignIdentity, campaignIdentitiesMatch } from "../spec-source-identity.mjs";
|
|
3
3
|
import { withHtmlScanSnapshot, readHtmlScanText } from "../html-scan.mjs";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import { accessSync, constants as fsConstants, existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
5
6
|
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
6
7
|
import { describeSdkIgnoredMetaTags, isSdkIgnoredMetaTag } from "../sdk-meta-tags.mjs";
|
|
@@ -90,6 +91,7 @@ import { SMOKE_QC, SMOKE_QC_LIMITS, builtFileOf, evaluateSmokeQc, insideRoot, is
|
|
|
90
91
|
import { recordQcResults } from "../qc-results.mjs";
|
|
91
92
|
import { FIGMA_EXPORT_FILE_CODES, SOURCE_PROVENANCE_SCOPE, evaluateSourceProvenanceGates, generatorClaimsFigmaExport, isSourceProvenanceCode } from "./source-provenance.mjs";
|
|
92
93
|
import { validateCampaignBuildBriefArtifact } from "../build-brief.mjs";
|
|
94
|
+
import { briefFileUnusable, deriveInputCurrency, wellFormedBriefMaterial } from "../input-currency.mjs";
|
|
93
95
|
import { ASSEMBLY_REPORT_STAGE_KEYS, stageIsTerminal } from "../orchestration-stage-contract.mjs";
|
|
94
96
|
import {
|
|
95
97
|
assemblySourcePackageFingerprintMissing,
|
|
@@ -106,7 +108,7 @@ import { evaluatePageKitSdkVersion, PAGE_KIT_SDK_VERSION_SCOPE } from "../page-k
|
|
|
106
108
|
// `npm run build:spec` (tsc -> campaign-spec/dist) so the package runs on the
|
|
107
109
|
// node engine in package.json without type-stripping. build runs on `prepare`,
|
|
108
110
|
// so a fresh install (including the git-ref consumer) always has dist.
|
|
109
|
-
import { normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
|
|
111
|
+
import { isReleasedSdkVersion, normalize as normalizeCampaignSpec, runRules, specOnlyRules } from "../../campaign-spec/dist/index.js";
|
|
110
112
|
import { cmd, asInvocation } from "../install-invocation.mjs";
|
|
111
113
|
import { specHashesMatch, specMaterialHash } from "../spec-identity.mjs";
|
|
112
114
|
import {
|
|
@@ -511,7 +513,10 @@ const PACKET_DOCTOR_CHECKS = createDoctorCheckRegistry([
|
|
|
511
513
|
{
|
|
512
514
|
id: "build_brief.artifact",
|
|
513
515
|
phase: "brief",
|
|
514
|
-
run: ({ packet, packetPath, spec, context, errors, warnings, ready }) =>
|
|
516
|
+
run: ({ packet, packetPath, spec, context, report, errors, warnings, ready, derived }) => {
|
|
517
|
+
validateBuildBrief(packet, packetPath, spec, context, errors, warnings, ready);
|
|
518
|
+
validateInputCurrency({ packet, packetPath, spec, report, warnings, derived });
|
|
519
|
+
},
|
|
515
520
|
},
|
|
516
521
|
], { registryId: "packet.always" });
|
|
517
522
|
|
|
@@ -731,22 +736,20 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
|
|
|
731
736
|
}
|
|
732
737
|
buildState.specStatus = specStatus;
|
|
733
738
|
if (specStatus === "ok") {
|
|
734
|
-
const
|
|
735
|
-
if (
|
|
736
|
-
|
|
737
|
-
&& !campaignIdentitiesMatch(campaignSpecIdentity(spec), packet.spec))) {
|
|
738
|
-
const localIdentity = spec.spec_identity?.local_spec_id != null || packet.spec?.local_spec_id != null;
|
|
739
|
-
addIssue(errors, localIdentity ? "spec.local_identity" : "spec.map_id", "Packet identity does not match the CampaignSpec map_id/local_spec_id.", { kind: localIdentity ? "local_spec" : "saved_map" });
|
|
739
|
+
const identityKind = specIdentityMismatch(spec, packet.spec);
|
|
740
|
+
if (identityKind) {
|
|
741
|
+
addIssue(errors, identityKind === "local_spec" ? "spec.local_identity" : "spec.map_id", "Packet identity does not match the CampaignSpec map_id/local_spec_id.", { kind: identityKind });
|
|
740
742
|
}
|
|
741
743
|
ready.push("Local CampaignSpec parsed");
|
|
742
|
-
//
|
|
743
|
-
//
|
|
744
|
-
// prints doctor's warnings) name it at the
|
|
744
|
+
// The spec no longer has the material hash the Assembly Report binds:
|
|
745
|
+
// a local spec, or the copy a saved-Map or gateway packet fetched.
|
|
746
|
+
// Doctor (and next, which prints doctor's warnings) name it at the
|
|
747
|
+
// first read.
|
|
745
748
|
const boundMaterialHash = buildState.report?.identity?.spec_material_hash;
|
|
746
|
-
if (
|
|
749
|
+
if (isNonEmptyString(boundMaterialHash)) {
|
|
747
750
|
const currentMaterialHash = specMaterialHash(spec);
|
|
748
751
|
if (!specHashesMatch(boundMaterialHash, currentMaterialHash)) {
|
|
749
|
-
addIssue(warnings, "spec.material_stale",
|
|
752
|
+
addIssue(warnings, "spec.material_stale", SPEC_MATERIAL_STALE_MESSAGE, { spec_path: localSpecPath, bound_material_hash: boundMaterialHash, current_material_hash: currentMaterialHash });
|
|
750
753
|
}
|
|
751
754
|
}
|
|
752
755
|
runDoctorChecks(SPEC_DOCTOR_CHECKS, { packet, packetPath, spec, targetRepo, errors, warnings, ready, derived, buildState });
|
|
@@ -762,6 +765,9 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
|
|
|
762
765
|
if (synthesizedBuiltSite) {
|
|
763
766
|
ready.push("Packet source/proof checks skipped for synthesized built-site packet");
|
|
764
767
|
} else {
|
|
768
|
+
// Computed once, before the checks that read it (the QA gate pass the
|
|
769
|
+
// built-output residue scan defers to) and the input warnings below.
|
|
770
|
+
derived.input_currency = deriveInputCurrency({ packet, packetPath, report: buildState.report, spec: isObject(spec) ? spec : null });
|
|
765
771
|
runDoctorChecks(PACKET_DOCTOR_CHECKS, { packet, packetPath, spec, context: buildState.context, report: buildState.report, errors, warnings, ready, derived, buildState });
|
|
766
772
|
}
|
|
767
773
|
|
|
@@ -781,6 +787,25 @@ function validatePacket(packet, packetPath, errors, warnings, ready, derived, bu
|
|
|
781
787
|
// stage progression.
|
|
782
788
|
}
|
|
783
789
|
|
|
790
|
+
// A stage's spec stamp, as record build, record polish and the QA write store it.
|
|
791
|
+
const SPEC_STAMP = /^sha256:[0-9a-f]{64}$/;
|
|
792
|
+
const SPEC_MATERIAL_STALE_MESSAGE = "CampaignSpec material changed since it was bound; run `record spec` (keeps history). `prepare-build --force` resets every stage and archives their records.";
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Whether a parsed CampaignSpec names another campaign than `bound` (the
|
|
796
|
+
* packet's spec block, or an Assembly Report identity): null when they
|
|
797
|
+
* agree, otherwise the kind of identity that differs ("local_spec" when
|
|
798
|
+
* either side carries a local spec id, else "saved_map").
|
|
799
|
+
*/
|
|
800
|
+
export function specIdentityMismatch(spec, bound) {
|
|
801
|
+
const specMapId = spec?.spec_identity?.map_id || spec?.map_id;
|
|
802
|
+
const localIdentity = spec?.spec_identity?.local_spec_id != null || bound?.local_spec_id != null;
|
|
803
|
+
if ((specMapId && specMapId !== bound?.map_id) || (localIdentity && !campaignIdentitiesMatch(campaignSpecIdentity(spec), bound))) {
|
|
804
|
+
return localIdentity ? "local_spec" : "saved_map";
|
|
805
|
+
}
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
|
|
784
809
|
function validateCampaignSpecRuleRegistry(spec, errors, warnings) {
|
|
785
810
|
// ADR-003: run the shared campaign-spec rule registry — the single public
|
|
786
811
|
// source of CampaignSpec validation. specOnlyRules is the right preset here:
|
|
@@ -876,7 +901,17 @@ function validateBuildBrief(packet, packetPath, spec, context, errors, warnings,
|
|
|
876
901
|
return;
|
|
877
902
|
}
|
|
878
903
|
|
|
879
|
-
|
|
904
|
+
let brief;
|
|
905
|
+
try {
|
|
906
|
+
brief = readJson(resolvedPath);
|
|
907
|
+
} catch {
|
|
908
|
+
addIssue(errors, "build_brief.normalized_path", `Campaign Build Brief normalized artifact is not valid JSON: ${normalizedPath}. Restore it, or save the brief again with ${cmd("record")} brief --packet <packet>.`);
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
if (!isObject(brief)) {
|
|
912
|
+
addIssue(errors, "build_brief.normalized_path", `Campaign Build Brief normalized artifact is not a valid brief object: ${normalizedPath}. Restore it, or save the brief again with ${cmd("record")} brief --packet <packet>.`);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
880
915
|
const result = validateCampaignBuildBriefArtifact(brief, { spec, normalizedPath });
|
|
881
916
|
for (const issue of result.errors) errors.push(issue);
|
|
882
917
|
for (const issue of result.warnings) warnings.push(issue);
|
|
@@ -887,6 +922,86 @@ function validateBuildBrief(packet, packetPath, spec, context, errors, warnings,
|
|
|
887
922
|
}
|
|
888
923
|
}
|
|
889
924
|
|
|
925
|
+
// derived.input_currency, computed once per doctor read, and the warnings it
|
|
926
|
+
// gives: a stage owed on the brief or on the CampaignSpec, a completed stage
|
|
927
|
+
// with no valid brief or spec stamp, or read against a brief or CampaignSpec
|
|
928
|
+
// that cannot be read, a brief file edited (or unreadable) since it was saved,
|
|
929
|
+
// and the build replay after an input change (owed, or kept by the
|
|
930
|
+
// operator's recorded decision). None is an error; `next` routes to the owed
|
|
931
|
+
// stage itself.
|
|
932
|
+
function validateInputCurrency({ packet, packetPath, spec, report, warnings, derived }) {
|
|
933
|
+
const currency = derived.input_currency || deriveInputCurrency({ packet, packetPath, report, spec: isObject(spec) ? spec : null });
|
|
934
|
+
derived.input_currency = currency;
|
|
935
|
+
if (!isObject(report?.stages)) return;
|
|
936
|
+
// A stage stamped with other spec content than the file holds now, while
|
|
937
|
+
// the report identity already names the file's content (an identity edited
|
|
938
|
+
// by hand): the same warning the identity comparison gives.
|
|
939
|
+
const owedOnSpec = Object.keys(currency.reasons).filter((key) => currency.reasons[key] === "spec_material_changed");
|
|
940
|
+
if (owedOnSpec.length && !warnings.some((issue) => issue.code === "spec.material_stale")) {
|
|
941
|
+
addIssue(warnings, "spec.material_stale", SPEC_MATERIAL_STALE_MESSAGE, { stages: owedOnSpec, bound_material_hash: currency.spec.bound, current_material_hash: currency.spec.current });
|
|
942
|
+
}
|
|
943
|
+
const specUnstamped = Object.keys(currency.stages).filter((key) => currency.reasons[key] === "input_binding_unknown" && !SPEC_STAMP.test(String(report.stages[key]?.source_spec_material_hash ?? "")));
|
|
944
|
+
if (specUnstamped.length) {
|
|
945
|
+
addIssue(warnings, "spec.binding_unknown", `Recorded ${specUnstamped.join(", ")} ${specUnstamped.length === 1 ? "does" : "do"} not say which CampaignSpec content ${specUnstamped.length === 1 ? "it was" : "they were"} made against, so ${specUnstamped.length === 1 ? "it reads" : "they read"} unconfirmed, not current. Record ${specUnstamped.join(", ")} again (${specUnstamped.map((key) => (key === "assembly" ? "record build" : key === "polish" ? "record polish" : "qa run")).join(", ")}).`, { stages: specUnstamped });
|
|
946
|
+
}
|
|
947
|
+
const owedOnBrief = Object.keys(currency.reasons).filter((key) => currency.reasons[key] === "brief_material_changed");
|
|
948
|
+
if (owedOnBrief.length) {
|
|
949
|
+
addIssue(warnings, "build_brief.material_changed", `The Campaign Build Brief's content changed since ${owedOnBrief.join(", ")} ${owedOnBrief.length === 1 ? "was" : "were"} recorded, so ${owedOnBrief.length === 1 ? "it is" : "they are"} owed again. Save the brief with ${cmd("record")} brief --packet <packet> (it keeps stage history), then record ${owedOnBrief.join(", ")} again.`, { stages: owedOnBrief });
|
|
950
|
+
}
|
|
951
|
+
const unstamped = Object.keys(currency.stages).filter((key) => {
|
|
952
|
+
const stage = report.stages[key];
|
|
953
|
+
if (currency.reasons[key] !== "input_binding_unknown") return false;
|
|
954
|
+
const stamp = stage?.source_brief_material;
|
|
955
|
+
return !wellFormedBriefMaterial(stamp);
|
|
956
|
+
});
|
|
957
|
+
if (unstamped.length) {
|
|
958
|
+
addIssue(warnings, "build_brief.binding_unknown", `Recorded ${unstamped.join(", ")} ${unstamped.length === 1 ? "does" : "do"} not say which Campaign Build Brief content ${unstamped.length === 1 ? "it was" : "they were"} made against, so ${unstamped.length === 1 ? "it reads" : "they read"} unconfirmed, not current. Record ${unstamped.join(", ")} again (${unstamped.map((key) => (key === "assembly" ? "record build" : key === "polish" ? "record polish" : "qa run")).join(", ")}).`, { stages: unstamped });
|
|
959
|
+
}
|
|
960
|
+
// A completed stage with a well-formed stamp also reads unknown when the
|
|
961
|
+
// current brief or CampaignSpec cannot be read: there is nothing to compare
|
|
962
|
+
// that stamp with. The warning names the file to restore; a stage whose
|
|
963
|
+
// stamp is missing or malformed is named by the stamp warning above only.
|
|
964
|
+
const unknownStages = Object.keys(currency.stages).filter((key) => currency.reasons[key] === "input_binding_unknown");
|
|
965
|
+
const unconfirmed = (stages) => `recorded ${stages.join(", ")} ${stages.length === 1 ? "reads" : "read"} unconfirmed, not current`;
|
|
966
|
+
const briefUnreadable = unknownStages.filter((key) => !unstamped.includes(key));
|
|
967
|
+
if (briefUnreadable.length && currency.brief.current === null) {
|
|
968
|
+
const normalizedPath = optionalString(packet?.build_brief?.normalized_path);
|
|
969
|
+
addIssue(warnings, "build_brief.binding_unknown", `${normalizedPath ? `The normalized Campaign Build Brief ${normalizedPath} cannot be read as a brief object` : "The Build Packet names no build_brief.normalized_path, so the current Campaign Build Brief cannot be read"}, so ${unconfirmed(briefUnreadable)}. Restore it, or save the brief again with ${cmd("record")} brief --packet <packet>.`, { stages: briefUnreadable, ...(normalizedPath ? { normalized_path: normalizedPath } : {}) });
|
|
970
|
+
}
|
|
971
|
+
const specPath = optionalString(packet?.spec?.local_path);
|
|
972
|
+
const specUnreadable = unknownStages.filter((key) => !specUnstamped.includes(key));
|
|
973
|
+
if (specUnreadable.length && currency.spec.current === null && specPath) {
|
|
974
|
+
addIssue(warnings, "spec.binding_unknown", `The CampaignSpec ${specPath} cannot be read as a JSON object, so ${unconfirmed(specUnreadable)}. Restore it, then run doctor again.`, { stages: specUnreadable, spec_path: specPath });
|
|
975
|
+
}
|
|
976
|
+
const inputPath = optionalString(packet?.build_brief?.input_path);
|
|
977
|
+
const savedSha = optionalString(report.build_brief?.input_sha256);
|
|
978
|
+
const briefFile = inputPath ? resolveFromFile(packetPath, inputPath) : null;
|
|
979
|
+
if (briefFile && savedSha && existsSync(briefFile)) {
|
|
980
|
+
// Read only a readable regular file: a pipe would block, a directory throws.
|
|
981
|
+
const unusable = briefFileUnusable(briefFile);
|
|
982
|
+
let bytes = null;
|
|
983
|
+
if (!unusable) {
|
|
984
|
+
try {
|
|
985
|
+
bytes = `sha256:${createHash("sha256").update(readFileSync(briefFile)).digest("hex")}`;
|
|
986
|
+
} catch {
|
|
987
|
+
bytes = null;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (bytes === null) {
|
|
991
|
+
addIssue(warnings, "build_brief.input_unsaved", `The brief file ${inputPath} ${unusable || "cannot be read"}, so doctor cannot tell whether it changed since it was saved; the build reads the saved brief. Restore it, or save a readable brief file with ${cmd("record")} brief --packet <packet> --brief <file>.`, { input_path: inputPath });
|
|
992
|
+
} else if (bytes !== savedSha) {
|
|
993
|
+
addIssue(warnings, "build_brief.input_unsaved", `The brief file ${inputPath} changed since it was saved. Save it with ${cmd("record")} brief --packet <packet>; until then the build reads the saved brief.`, { input_path: inputPath });
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (currency.reasons.assembly === "output_unchanged_after_input_change") {
|
|
997
|
+
addIssue(warnings, "assembly.output_unchanged_after_input_change", `The build output is unchanged since the brief or CampaignSpec change was recorded, so the change has not reached the pages and build is owed again. Rebuild, or, only on the operator's explicit decision that the change needs no output change, record build with --deviation-reason "<the operator's reason>".`);
|
|
998
|
+
}
|
|
999
|
+
const keptReason = report.stages.assembly?.unchanged_output_reason;
|
|
1000
|
+
if (currency.stages.assembly === "current" && isObject(keptReason) && typeof keptReason.text === "string") {
|
|
1001
|
+
addIssue(warnings, "assembly.output_unchanged_by_operator_decision", `The build output was kept unchanged after an input change by the operator's recorded decision: "${keptReason.text}".`, { reason: keptReason.text });
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
890
1005
|
function validateProofPolicyObject(policy, location, warnings, ready, { requireBrowserQa = false } = {}) {
|
|
891
1006
|
if (!isObject(policy)) {
|
|
892
1007
|
addIssue(warnings, location, `${location} must be an object when present.`);
|
|
@@ -2407,7 +2522,7 @@ function recordSmokeQc({ subject, targetRepo, pages, environment, deployBase, wa
|
|
|
2407
2522
|
}
|
|
2408
2523
|
|
|
2409
2524
|
function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived }) {
|
|
2410
|
-
const gate = evaluateSdkMarkup({ subject, pages });
|
|
2525
|
+
const gate = evaluateSdkMarkup({ subject, pages, sdkVersion: campaignSdkPin(derived?.target_repo, subject?.public_route_slug) });
|
|
2411
2526
|
if (Array.isArray(derived?.checkpoint_gates)) derived.checkpoint_gates.push(gate);
|
|
2412
2527
|
|
|
2413
2528
|
if (gate.status === "not_applicable") {
|
|
@@ -2438,6 +2553,16 @@ function recordSdkMarkupGate({ subject, pages, errors, warnings, ready, derived
|
|
|
2438
2553
|
return gate;
|
|
2439
2554
|
}
|
|
2440
2555
|
|
|
2556
|
+
// The campaign's SDK pin from the target's campaigns.json entry, when it is an
|
|
2557
|
+
// exact released version; else null. The SDK markup check reads a page's own
|
|
2558
|
+
// loader pin first and falls back to this one.
|
|
2559
|
+
function campaignSdkPin(targetRepo, publicRouteSlug) {
|
|
2560
|
+
if (!targetRepo || !publicRouteSlug) return null;
|
|
2561
|
+
const load = loadPageKitCampaignEntry({ targetRepo, publicRouteSlug });
|
|
2562
|
+
const version = load.status === "ok" ? load.entry?.sdk_version : null;
|
|
2563
|
+
return isReleasedSdkVersion(version) ? version : null;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2441
2566
|
// Route drift: a CampaignSpec page whose declared route has no built page at
|
|
2442
2567
|
// that path. page-kit derives the public route from the source FILENAME, so a
|
|
2443
2568
|
// file named presell-running.html builds at /presell-running/ even if the spec
|
|
@@ -4257,7 +4382,7 @@ export function validateBuiltPlaceholderTextResidue(brandContract, warnings, rea
|
|
|
4257
4382
|
return;
|
|
4258
4383
|
}
|
|
4259
4384
|
const terms = [...new Set(hits.map((hit) => hit.label))].join(", ");
|
|
4260
|
-
if (report && qaGatePassedForCurrentBuild(report, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, { buildFingerprint: currentBuildFingerprint(report) })) {
|
|
4385
|
+
if (report && qaGatePassedForCurrentBuild(report, QA_GATE_PLACEHOLDER_TEXT_RESIDUE, { buildFingerprint: currentBuildFingerprint(report), qaCurrency: derived.input_currency?.stages?.qa ?? "unknown" })) {
|
|
4261
4386
|
ready.push(`Static scan still sees placeholder-term text (${terms}: ${summarizeCopyMatches(hits)}), but the browser residue gate passed on this build; QA's rendered-text verdict stands.`);
|
|
4262
4387
|
return;
|
|
4263
4388
|
}
|
package/src/doctor/inspect.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { withHtmlScanSnapshot } from "../html-scan.mjs";
|
|
|
5
5
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join, resolve } from "node:path";
|
|
7
7
|
import { shellToken } from "../shell-token.mjs";
|
|
8
|
+
import { cmd } from "../install-invocation.mjs";
|
|
8
9
|
import { commitAssemblyReport, recordProducerStageOutcome } from "../stage-ledger.mjs";
|
|
9
10
|
import { annotateDoctorIssueCauses } from "../finding-cause.mjs";
|
|
10
11
|
import { DOCTOR_SIDECAR_SCHEMA } from "../doctor-sidecar.mjs";
|
|
@@ -244,6 +245,18 @@ export function doctorBuiltOutput(args) {
|
|
|
244
245
|
qc_results: [],
|
|
245
246
|
};
|
|
246
247
|
ready.push(`Resolved ${scope.html_count} built page(s) from ${relFromDir(targetRepo, scope.campaign_dir)} (slug "${scope.slug || "(site root)"}")`);
|
|
248
|
+
// #583: --base-url reads like a check of the deployed site, but these checks
|
|
249
|
+
// read only the local built files. Say so wherever the URL is given, and
|
|
250
|
+
// name the command that does load the served pages.
|
|
251
|
+
if (baseUrl) {
|
|
252
|
+
derived.base_url = { value: baseUrl, fetched: false, recorded_as: "deploy.preview_url" };
|
|
253
|
+
// Without --family there is no value to print that the CLI would accept,
|
|
254
|
+
// so the command is printed only when it is runnable as shown.
|
|
255
|
+
const servedCheck = family
|
|
256
|
+
? `To check the served pages, run ${cmd("qa", `run --site ${shellToken(String(args.built || args.site))} --base-url ${shellToken(baseUrl)} --family ${shellToken(family)} --browser`)}.`
|
|
257
|
+
: `To check the served pages, run ${cmd("qa")} run --site with this --base-url and the campaign's --family, plus --browser.`;
|
|
258
|
+
ready.push(`Checked the local built files only: --base-url ${baseUrl} was not fetched; it only fills deploy.preview_url in the minimal Build Packet. ${servedCheck}`);
|
|
259
|
+
}
|
|
247
260
|
|
|
248
261
|
const resolution = resolveBrandContractOnce(derived, family);
|
|
249
262
|
let brandContract = null;
|
package/src/doctor/next-step.mjs
CHANGED
|
@@ -12,12 +12,12 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
NEXT_STAGE_ORDER,
|
|
14
14
|
NEXT_STAGE_OWNERS,
|
|
15
|
-
STAGE_TERMINAL_STATUS_PREFIXES,
|
|
16
15
|
reportKeyForCliStage,
|
|
17
16
|
stageIsBlocked,
|
|
18
17
|
stageIsTerminal,
|
|
19
18
|
} from "../orchestration-stage-contract.mjs";
|
|
20
19
|
import { evaluatePolishGate } from "../polish-gate.mjs";
|
|
20
|
+
import { effectiveStageStatus, effectiveStatusIsTerminal } from "../input-currency.mjs";
|
|
21
21
|
import { cmd } from "../install-invocation.mjs";
|
|
22
22
|
import { isObject, isNonEmptyString, optionalString, resolveFromFile, addIssue, filesystemPathsMatch } from "../cli-helpers.mjs";
|
|
23
23
|
import { orderPathDepthDrift } from "./checks.mjs";
|
|
@@ -440,6 +440,16 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
440
440
|
};
|
|
441
441
|
}
|
|
442
442
|
|
|
443
|
+
// A build made against earlier brief or CampaignSpec content, or one whose
|
|
444
|
+
// inputs cannot be confirmed, is owed before anything downstream of it.
|
|
445
|
+
const inputCurrency = derived?.input_currency || null;
|
|
446
|
+
if (["owed", "unknown"].includes(inputCurrency?.stages?.assembly)) {
|
|
447
|
+
return {
|
|
448
|
+
stage: "build",
|
|
449
|
+
reason: inputCurrency.reasons?.assembly || "input_binding_unknown",
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
443
453
|
if (polishGate.status === "blocked") {
|
|
444
454
|
if (polishGateRequiresBuild(polishGate)) {
|
|
445
455
|
return {
|
|
@@ -474,7 +484,9 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
474
484
|
reason: `Stage "${reportKey}" is not recorded in the assembly report; run "${cliStage}" next.`,
|
|
475
485
|
};
|
|
476
486
|
}
|
|
477
|
-
|
|
487
|
+
// The effective status: a stage owed again by an input change reads
|
|
488
|
+
// required, and one whose inputs or raw status cannot be read, unknown.
|
|
489
|
+
const status = effectiveStageStatus(reportKey, stage, inputCurrency);
|
|
478
490
|
if (stageIsBlocked(status)) {
|
|
479
491
|
return {
|
|
480
492
|
stage: cliStage,
|
|
@@ -482,10 +494,7 @@ function pickNextStage(report, { errors = [], derived = null }, prepareBuildGate
|
|
|
482
494
|
blocked: true,
|
|
483
495
|
};
|
|
484
496
|
}
|
|
485
|
-
|
|
486
|
-
// (e.g. "completed_partial") count as terminal.
|
|
487
|
-
const isTerminal = STAGE_TERMINAL_STATUS_PREFIXES.some((t) => status.startsWith(t));
|
|
488
|
-
if (!isTerminal) {
|
|
497
|
+
if (!effectiveStatusIsTerminal(status)) {
|
|
489
498
|
return {
|
|
490
499
|
stage: cliStage,
|
|
491
500
|
reason: `Stage "${reportKey}" has status "${status || "(unset)"}"; run "${cliStage}" next.`,
|