@nextcommerce/campaigns-os 1.37.3 → 1.41.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +114 -10
- package/CHANGELOG.md +530 -0
- package/README.md +38 -27
- package/contracts/agent-relevant-change-policy.v1.json +11 -1
- package/contracts/effects.v1.json +4794 -0
- package/contracts/release-ledger.json +789 -0
- package/contracts/supported-surface.json +25 -5
- package/docs/build-packet.md +27 -16
- package/docs/demo-preview.md +1 -1
- package/docs/diagnostics.md +7 -4
- package/docs/effects.md +281 -0
- package/docs/gateway-login.md +113 -0
- package/docs/orientation-contract-reference.md +4 -1
- package/docs/progress-snapshots.md +3 -3
- package/docs/qa-and-test-orders.md +3 -3
- package/docs/readback.md +523 -0
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +364 -0
- package/docs/supported-surface.md +11 -3
- package/docs/versioning.md +8 -4
- package/package.json +8 -3
- package/schemas/campaign-runtime-build-packet.v0.schema.json +5 -0
- package/schemas/campaigns-os-effects.v1.schema.json +211 -0
- package/schemas/campaigns-os-readback.v2.schema.json +267 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +174 -0
- package/skills/campaign-readback-classification/SKILL.md +230 -0
- package/skills/campaign-run-evidence/SKILL.md +140 -0
- package/skills/contribution-intake/SKILL.md +85 -0
- package/skills/next-campaigns-build/SKILL.md +33 -12
- package/skills/next-campaigns-os/SKILL.md +45 -21
- package/skills/next-campaigns-os-setup/SKILL.md +35 -14
- package/skills/next-campaigns-polish/SKILL.md +43 -17
- package/skills/next-campaigns-qa/SKILL.md +48 -24
- package/skills.json +39 -6
- package/src/admin-transport.mjs +123 -0
- package/src/cli.mjs +991 -200
- package/src/credential-store.mjs +183 -0
- package/src/deviation.mjs +3 -2
- package/src/diagnostic.mjs +4 -1
- package/src/gate-actions.mjs +2 -2
- package/src/install-mode.mjs +17 -9
- package/src/lifecycle.mjs +95 -0
- package/src/login.mjs +152 -0
- package/src/package-install-fixture.mjs +3 -2
- package/src/qa-node.mjs +56 -19
- package/src/qa-publish.mjs +108 -2
- package/src/readback.mjs +1936 -0
- package/src/remit.mjs +17 -3
package/src/readback.mjs
ADDED
|
@@ -0,0 +1,1936 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only projection of one run's emitted Campaigns OS artifacts.
|
|
3
|
+
*
|
|
4
|
+
* `campaigns-os readback <target>` is a deterministic, side-effect-free
|
|
5
|
+
* projection over files a Campaigns OS run has already emitted (the Build
|
|
6
|
+
* Packet, the doctor output sidecar, the build context, the assembly report, a
|
|
7
|
+
* QA verdict, and a findings export when present). It reads each named file at
|
|
8
|
+
* most once, plus two fixed Git metadata files for the staleness comparison
|
|
9
|
+
* (the nearest `.git` entry at the target or one of its ancestors — funnels are
|
|
10
|
+
* usually subdirectories of their enclosing campaign repository — to locate the
|
|
11
|
+
* Git directory, and that directory's `logs/HEAD` reflog). It writes nothing,
|
|
12
|
+
* starts no process, and touches no network. That contract is why the CLI
|
|
13
|
+
* exempts `readback` from lifecycle-journal capture the way it exempts doctor
|
|
14
|
+
* inspection: a command declared read-only must not append a journal entry.
|
|
15
|
+
*
|
|
16
|
+
* Campaigns OS remains the lifecycle and verdict authority. The readback never
|
|
17
|
+
* reinterprets a verdict and never proposes or performs remediation; where it
|
|
18
|
+
* adds anything beyond the artifacts' own words — the contract-static warning
|
|
19
|
+
* labels, the fail-to-skip cascade provenance, the staleness assessment — the
|
|
20
|
+
* rendered output marks that content as the readback's own projection layer.
|
|
21
|
+
*
|
|
22
|
+
* The default output is the rendered human view. `--json` emits the same
|
|
23
|
+
* projection as one `campaigns-os-readback/v2` object on stdout so a caller can
|
|
24
|
+
* gate on it; the JSON serializes what the text view already computes and adds
|
|
25
|
+
* no new interpretation. `docs/readback.md` is that contract's prose twin,
|
|
26
|
+
* including the exact rule behind its `clean` flag, and
|
|
27
|
+
* `schemas/campaigns-os-readback.v2.schema.json` is its shape.
|
|
28
|
+
*
|
|
29
|
+
* This module is a port of the Python readback this command replaces. The port
|
|
30
|
+
* keeps that module's decomposition section by section so the two can be
|
|
31
|
+
* diffed, and fixes one defect in it: staleness is now assessed per artifact.
|
|
32
|
+
* The Python version compared only the newest loaded artifact against HEAD, so
|
|
33
|
+
* a single freshly regenerated artifact hid every stale sibling behind
|
|
34
|
+
* `stale: false`. Here every loaded artifact with a parseable `generated_at`
|
|
35
|
+
* gets its own verdict, `stale_keys` names the stale ones in render order, and
|
|
36
|
+
* the aggregate `stale` is true when ANY of them is stale. That is a change of
|
|
37
|
+
* meaning in a published field, so the payload is `v2`, not `v1`. An artifact
|
|
38
|
+
* that recorded a `generated_at` this readback cannot parse is the same defect
|
|
39
|
+
* one step further out — its age was never established, so it is named in
|
|
40
|
+
* `unparseable_keys` and makes `clean` false rather than leaving a fresh
|
|
41
|
+
* sibling to speak for it.
|
|
42
|
+
*
|
|
43
|
+
* Same-user artifact files are inside the repository's trust boundary, so this
|
|
44
|
+
* module deliberately has no symlink or tamper ceremony: a caller who wants to
|
|
45
|
+
* feed it other bytes can already read those bytes directly.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { closeSync, fstatSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
49
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
50
|
+
import { fileURLToPath } from "node:url";
|
|
51
|
+
|
|
52
|
+
export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
|
|
53
|
+
export const MAX_GIT_METADATA_BYTES = 64 * 1024;
|
|
54
|
+
|
|
55
|
+
/** A bounded read refused because the file is larger than its limit. */
|
|
56
|
+
export class ReadLimitError extends Error {
|
|
57
|
+
constructor(message) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.name = "ReadLimitError";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A file's bytes are not valid UTF-8. */
|
|
64
|
+
export class UnicodeDecodeError extends Error {
|
|
65
|
+
constructor(message) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = "UnicodeDecodeError";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A bounded reflog read could not recover a complete final entry. */
|
|
72
|
+
export class ReflogTailError extends Error {
|
|
73
|
+
constructor(message) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = "ReflogTailError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Raised when the caller's paths cannot form a projection at all. */
|
|
80
|
+
export class ReadbackUsageError extends Error {
|
|
81
|
+
constructor(message) {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = "ReadbackUsageError";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// `ignoreBOM: true` means "do not strip a leading U+FEFF", which is what the
|
|
88
|
+
// Python reader's `payload.decode("utf-8")` does: the BOM survives decoding as
|
|
89
|
+
// a character, and `json.loads` then refuses the value outright ("Unexpected
|
|
90
|
+
// UTF-8 BOM"). The default TextDecoder swallows that BOM instead, which made a
|
|
91
|
+
// BOM-prefixed packet parse here and fail there — and a packet that parses is a
|
|
92
|
+
// discovery candidate, so the byte order mark silently changed which artifact
|
|
93
|
+
// the readback projected. Keeping the BOM keeps a BOM-prefixed file unreadable
|
|
94
|
+
// in both implementations, which is what discovery records as rejected.
|
|
95
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
96
|
+
|
|
97
|
+
function decodeUtf8(bytes) {
|
|
98
|
+
try {
|
|
99
|
+
return UTF8.decode(bytes);
|
|
100
|
+
} catch {
|
|
101
|
+
throw new UnicodeDecodeError("'utf-8' codec cannot decode the file's bytes: invalid UTF-8");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function readExactly(fd, length, position) {
|
|
106
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
107
|
+
let filled = 0;
|
|
108
|
+
while (filled < length) {
|
|
109
|
+
const read = readSync(fd, buffer, filled, length - filled, position + filled);
|
|
110
|
+
if (read === 0) break;
|
|
111
|
+
filled += read;
|
|
112
|
+
}
|
|
113
|
+
return buffer.subarray(0, filled);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Read a whole file as UTF-8 text, refusing anything past `maxBytes`.
|
|
118
|
+
*
|
|
119
|
+
* The limit is a read bound, not a truncation: a file one byte over it is
|
|
120
|
+
* refused rather than silently shortened, because a half-read artifact would
|
|
121
|
+
* project as malformed JSON and read as the run's fault rather than ours.
|
|
122
|
+
*/
|
|
123
|
+
export function readBoundedText(path, maxBytes) {
|
|
124
|
+
const fd = openSync(path, "r");
|
|
125
|
+
let payload;
|
|
126
|
+
try {
|
|
127
|
+
const size = fstatSync(fd).size;
|
|
128
|
+
payload = readExactly(fd, Math.min(size, maxBytes + 1), 0);
|
|
129
|
+
} finally {
|
|
130
|
+
closeSync(fd);
|
|
131
|
+
}
|
|
132
|
+
if (payload.length > maxBytes) throw new ReadLimitError(`file exceeds the ${maxBytes}-byte read limit`);
|
|
133
|
+
return decodeUtf8(payload);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const BLANK_BYTES = new Set([0x20, 0x09, 0x0a, 0x0d, 0x0b, 0x0c]);
|
|
137
|
+
|
|
138
|
+
function isBlank(bytes) {
|
|
139
|
+
for (const byte of bytes) if (!BLANK_BYTES.has(byte)) return false;
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function splitLines(bytes) {
|
|
144
|
+
const lines = [];
|
|
145
|
+
let start = 0;
|
|
146
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
147
|
+
if (bytes[index] === 0x0a) {
|
|
148
|
+
lines.push(bytes.subarray(start, index));
|
|
149
|
+
start = index + 1;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (start < bytes.length) lines.push(bytes.subarray(start));
|
|
153
|
+
return lines;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The last complete entry of a reflog, reading only the file's final tail.
|
|
158
|
+
*
|
|
159
|
+
* A long-lived checkout's reflog is unbounded, and only its last line is the
|
|
160
|
+
* signal, so the read is bounded from the end. The leading fragment of a
|
|
161
|
+
* bounded tail may start mid-entry or mid-codepoint, so it is discarded up to
|
|
162
|
+
* the first newline; only the final line is decoded, which keeps a corrupt
|
|
163
|
+
* older entry from failing a read whose answer does not depend on it.
|
|
164
|
+
*/
|
|
165
|
+
export function readReflogTail(path) {
|
|
166
|
+
const fd = openSync(path, "r");
|
|
167
|
+
let payload;
|
|
168
|
+
let start;
|
|
169
|
+
try {
|
|
170
|
+
const size = fstatSync(fd).size;
|
|
171
|
+
start = Math.max(0, size - MAX_GIT_METADATA_BYTES);
|
|
172
|
+
payload = readExactly(fd, Math.min(size - start, MAX_GIT_METADATA_BYTES), start);
|
|
173
|
+
} finally {
|
|
174
|
+
closeSync(fd);
|
|
175
|
+
}
|
|
176
|
+
if (start) {
|
|
177
|
+
const newline = payload.indexOf(0x0a);
|
|
178
|
+
if (newline === -1) throw new ReflogTailError("no newline found within the bounded tail");
|
|
179
|
+
payload = payload.subarray(newline + 1);
|
|
180
|
+
if (isBlank(payload)) throw new ReflogTailError("no complete entry remains after the leading fragment");
|
|
181
|
+
}
|
|
182
|
+
const entries = splitLines(payload).filter((line) => !isBlank(line));
|
|
183
|
+
return entries.length ? decodeUtf8(entries[entries.length - 1]) : "";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Artifact keys, in render order, with the default location of each artifact
|
|
187
|
+
// relative to the target repository root. The packet lives at the root; the
|
|
188
|
+
// sidecars live under .campaign-runtime/.
|
|
189
|
+
export const DEFAULT_RELATIVE_PATHS = {
|
|
190
|
+
packet: "campaign-runtime.build.json",
|
|
191
|
+
doctor: ".campaign-runtime/doctor-output.json",
|
|
192
|
+
context: ".campaign-runtime/build-context.json",
|
|
193
|
+
report: ".campaign-runtime/assembly-report.json",
|
|
194
|
+
qa_verdict: ".campaign-runtime/qa-verdict.json",
|
|
195
|
+
findings: ".campaign-runtime/findings-export.json",
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export const ARTIFACT_TITLES = {
|
|
199
|
+
packet: "build packet",
|
|
200
|
+
doctor: "doctor output",
|
|
201
|
+
context: "build context",
|
|
202
|
+
report: "assembly report",
|
|
203
|
+
qa_verdict: "QA verdict",
|
|
204
|
+
findings: "findings export",
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const RECOGNIZED_SCHEMA_VERSIONS = {
|
|
208
|
+
packet: ["campaign-runtime-build-packet/v0"],
|
|
209
|
+
context: ["campaign-runtime-build-context/v0"],
|
|
210
|
+
report: ["campaign-runtime-assembly-report/v0"],
|
|
211
|
+
// Campaigns OS has emitted both spellings for the QA verdict.
|
|
212
|
+
qa_verdict: ["1.0", "campaigns-os-qa-verdict/v0"],
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// Root-level Build Packets other than the default-named one. A second run in
|
|
216
|
+
// the same repository leaves its record under a suffixed name; both shapes are
|
|
217
|
+
// discovery candidates when no `--packet` was given.
|
|
218
|
+
const PACKET_CANDIDATE_PATTERN = /^campaign-runtime-.*\.build\.json$/;
|
|
219
|
+
|
|
220
|
+
// Sidecar path some write-ends have used instead of the contracted root home.
|
|
221
|
+
// Discovery never selects this file: Campaigns OS writes the packet at the
|
|
222
|
+
// repository root, and auto-selecting the sidecar would paper over that
|
|
223
|
+
// mismatch. When no root candidate exists and this file is present, discovery
|
|
224
|
+
// records it as rejected so the operator can pass --packet instead of staring
|
|
225
|
+
// at an empty folder.
|
|
226
|
+
const SIDECAR_PACKET_RELATIVE = ".campaign-runtime/campaign-runtime.build.json";
|
|
227
|
+
|
|
228
|
+
// The readback's own interpretation layer, applied to doctor warning codes.
|
|
229
|
+
// Doctor warnings under the frontmatter.* codes restate the template family's
|
|
230
|
+
// shared frontmatter vocabulary (the contract), not an observation of this
|
|
231
|
+
// repository: they repeat verbatim on every doctor pass while the contract is
|
|
232
|
+
// in force, so their persistence does not mean a flagged value is still
|
|
233
|
+
// unfixed, and their disappearance is not how a fix is confirmed. Every other
|
|
234
|
+
// code is labeled repo-observed: not in the contract-static table, so its
|
|
235
|
+
// message reflects this repository, spec, or build as doctor saw it.
|
|
236
|
+
const CONTRACT_STATIC_CODE_PREFIXES = ["frontmatter."];
|
|
237
|
+
|
|
238
|
+
// Stage blockers render in full up to this cap, then collapse to a count, so a
|
|
239
|
+
// pathological report cannot flood the view. Known-schema object blockers
|
|
240
|
+
// (code/message/stage/page_id/detail) render their human-readable text in full
|
|
241
|
+
// — the stage cap is the volume bound — while unknown shapes fall back to a
|
|
242
|
+
// bounded JSON rendering.
|
|
243
|
+
const BLOCKER_RENDER_CAP = 10;
|
|
244
|
+
const NON_STRING_BLOCKER_RENDER_CAP = 120;
|
|
245
|
+
|
|
246
|
+
// The human-readable and identifier fields of the assembly report's object
|
|
247
|
+
// blocker schema, in render order. detail substitutes when message is absent.
|
|
248
|
+
// This tuple mirrors the upstream assembly-report schema and has to move with
|
|
249
|
+
// it: a renamed or removed field is skipped silently rather than reported, and
|
|
250
|
+
// a newly added identifier is not rendered until it is listed here.
|
|
251
|
+
const BLOCKER_IDENTIFIER_FIELDS = ["stage", "page_id"];
|
|
252
|
+
|
|
253
|
+
const CONTRACT_STATIC_LABEL_NOTE =
|
|
254
|
+
"contract-static: restates the template-family contract; repeats verbatim " +
|
|
255
|
+
"on every doctor pass, so its presence does not track this repository's " +
|
|
256
|
+
"current state";
|
|
257
|
+
const REPO_OBSERVED_LABEL_NOTE =
|
|
258
|
+
"repo-observed: reflects this repository, spec, or build as doctor saw it";
|
|
259
|
+
|
|
260
|
+
// The machine-readable projection's schema identifier. Contract and field
|
|
261
|
+
// semantics live in docs/readback.md and
|
|
262
|
+
// schemas/campaigns-os-readback.v2.schema.json; those documents and this
|
|
263
|
+
// constant move together.
|
|
264
|
+
export const JSON_SCHEMA_VERSION = "campaigns-os-readback/v2";
|
|
265
|
+
|
|
266
|
+
// Artifact states that do not, on their own, make a projection unclean: a
|
|
267
|
+
// loaded artifact was read and recognized, and an absent one is a file the run
|
|
268
|
+
// simply did not emit here. `unreadable` and `unrecognized` mean the readback
|
|
269
|
+
// cannot see what the artifact says, which is never clean.
|
|
270
|
+
const CLEAN_ARTIFACT_STATES = new Set(["loaded", "absent"]);
|
|
271
|
+
|
|
272
|
+
// Python's datetime range (year 1 through year 9999), kept so an absurd reflog
|
|
273
|
+
// epoch is refused with a reason instead of formatting as a nonsense instant.
|
|
274
|
+
const MIN_EPOCH_SECONDS = -62135596800;
|
|
275
|
+
const MAX_EPOCH_SECONDS = 253402300799;
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// ISO-8601 acceptance, ported from CPython
|
|
279
|
+
//
|
|
280
|
+
// The Python readback parses `generated_at` with `datetime.fromisoformat`
|
|
281
|
+
// (runner/artifact_readback.py:192-209, `_parse_iso_timestamp`), so acceptance
|
|
282
|
+
// here has to match that function in BOTH directions, not merely cover the
|
|
283
|
+
// shapes Campaigns OS emits. A value Python parses and this does not drops the
|
|
284
|
+
// artifact out of the staleness map, which reports a stale sibling as clean; a
|
|
285
|
+
// value Python refuses and this accepts turns a required unknown-candidate
|
|
286
|
+
// refusal into a silent packet selection. A regex tuned to the emitted shape
|
|
287
|
+
// got both wrong — it capped the fraction at nine digits and never bounded the
|
|
288
|
+
// UTC offset — so the grammar below is a transcription of CPython's helpers
|
|
289
|
+
// (Lib/datetime.py, 3.11) rather than an approximation of them: one function
|
|
290
|
+
// per Python helper, named for it, in the same order.
|
|
291
|
+
//
|
|
292
|
+
// Where the pure-Python reference and the C accelerator disagree, this follows
|
|
293
|
+
// the C, because the accelerator is the implementation that actually runs when
|
|
294
|
+
// the Python readback calls `fromisoformat`. The disagreements this grammar
|
|
295
|
+
// carries, each checked against CPython 3.11's accelerator rather than inferred
|
|
296
|
+
// from the pure-Python source:
|
|
297
|
+
//
|
|
298
|
+
// - `int()` accepts surrounding whitespace, a sign, non-ASCII digits, and a
|
|
299
|
+
// one-digit slice where the C `parse_digits` demands an exact count; this
|
|
300
|
+
// takes strict ASCII digits at exact widths.
|
|
301
|
+
// - a fraction may follow `HH` or `HH:MM`, not only `HH:MM:SS`: the C parser
|
|
302
|
+
// starts the fraction wherever the time components stop, so `T10.5` and
|
|
303
|
+
// `T10:00.5` are instants. Pure Python calls both an invalid separator.
|
|
304
|
+
// - the date/time separator is one Unicode character, not one UTF-16 code
|
|
305
|
+
// unit, so an astral separator is consumed whole.
|
|
306
|
+
// - a separator with no time behind it (`2026-09-22T`) is malformed. Pure
|
|
307
|
+
// Python reads it as midnight; the C parser refuses it, and a bare date
|
|
308
|
+
// with no separator at all is still midnight in both.
|
|
309
|
+
// - an offset whose WHOLE-second part is zero is UTC, and its sub-second
|
|
310
|
+
// part is discarded (`+00:00:00.5` is UTC, not half a second east). Pure
|
|
311
|
+
// Python builds a half-second timezone. An offset with a non-zero
|
|
312
|
+
// whole-second part keeps its fraction in both (`+00:00:01.5`).
|
|
313
|
+
//
|
|
314
|
+
// One C quirk is deliberately NOT ported: the accelerator also reads `:` as a
|
|
315
|
+
// fraction separator after the seconds (`T10:00:00:12` is 10:00:00.12), which
|
|
316
|
+
// pure Python refuses. No finding covers it, no artifact is written that way,
|
|
317
|
+
// and this grammar refuses it as the pure-Python reference does.
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
const MIN_ISO_YEAR = 1;
|
|
321
|
+
const MAX_ISO_YEAR = 9999;
|
|
322
|
+
const MICROS_PER_SECOND = 1_000_000n;
|
|
323
|
+
const MILLIS_PER_DAY = 86_400_000;
|
|
324
|
+
// The ordinal of 1970-01-01 in Python's proleptic Gregorian calendar, where
|
|
325
|
+
// 0001-01-01 is ordinal 1. JavaScript's Date uses the same calendar, so day
|
|
326
|
+
// arithmetic can cross between the two through this constant alone.
|
|
327
|
+
const UNIX_EPOCH_ORDINAL = 719163;
|
|
328
|
+
// `timezone()`'s own bound: strictly between -24h and +24h, which is at most
|
|
329
|
+
// 23:59:59.999999 either way (Lib/datetime.py `timezone._maxoffset`). This is
|
|
330
|
+
// the check the regex had no equivalent of, and the reason `+25:00` has to be
|
|
331
|
+
// unparseable rather than a 25-hour shift.
|
|
332
|
+
const MAX_OFFSET_MICROS = 24n * 3600n * MICROS_PER_SECOND - 1n;
|
|
333
|
+
// Scale for a fraction shorter than six digits, indexed by digits - 1
|
|
334
|
+
// (Lib/datetime.py `_FRACTION_CORRECTION`).
|
|
335
|
+
const FRACTION_CORRECTION = [100000, 10000, 1000, 100, 10];
|
|
336
|
+
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
337
|
+
// The characters that can start a UTC offset, in the order `_parse_isoformat_time`
|
|
338
|
+
// consults them: a `-` anywhere in the time wins over a `+` or a `Z` that comes
|
|
339
|
+
// earlier, and `Z` is reached only when neither sign appears. See the scan in
|
|
340
|
+
// parseIsoformatTime for the measurement behind that order.
|
|
341
|
+
const OFFSET_MARKERS = ["-", "+", "Z"];
|
|
342
|
+
|
|
343
|
+
function isAsciiDigit(code) {
|
|
344
|
+
return code >= 48 && code <= 57;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** CPython `parse_digits`: exactly `count` ASCII digits at `pos`, else null. */
|
|
348
|
+
function parseDigits(text, pos, count) {
|
|
349
|
+
if (pos + count > text.length) return null;
|
|
350
|
+
let value = 0;
|
|
351
|
+
for (let index = 0; index < count; index += 1) {
|
|
352
|
+
const code = text.charCodeAt(pos + index);
|
|
353
|
+
if (!isAsciiDigit(code)) return null;
|
|
354
|
+
value = value * 10 + (code - 48);
|
|
355
|
+
}
|
|
356
|
+
return value;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function isLeapYear(year) {
|
|
360
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function daysInMonth(year, month) {
|
|
364
|
+
return month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Milliseconds since the epoch for a UTC calendar date and time of day.
|
|
369
|
+
*
|
|
370
|
+
* `Date.UTC` maps years 0-99 onto 1900-1999, which would put every year Python
|
|
371
|
+
* accepts below 100 in the wrong millennium; the round-trip through
|
|
372
|
+
* `setUTCFullYear` is how the literal year is kept.
|
|
373
|
+
*/
|
|
374
|
+
function utcMillis(year, month, day, hour = 0, minute = 0, second = 0) {
|
|
375
|
+
const stamp = Date.UTC(year, month - 1, day, hour, minute, second);
|
|
376
|
+
if (year > 99) return stamp;
|
|
377
|
+
const corrected = new Date(stamp);
|
|
378
|
+
corrected.setUTCFullYear(year);
|
|
379
|
+
return corrected.getTime();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** CPython `_ymd2ord`: days since 0001-01-01, counting that day as 1. */
|
|
383
|
+
function ymdToOrdinal(year, month, day) {
|
|
384
|
+
return utcMillis(year, month, day) / MILLIS_PER_DAY + UNIX_EPOCH_ORDINAL;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** CPython `_ord2ymd`: the inverse of ymdToOrdinal. */
|
|
388
|
+
function ordinalToYmd(ordinal) {
|
|
389
|
+
const moment = new Date((ordinal - UNIX_EPOCH_ORDINAL) * MILLIS_PER_DAY);
|
|
390
|
+
return { year: moment.getUTCFullYear(), month: moment.getUTCMonth() + 1, day: moment.getUTCDate() };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** CPython `_isoweek1monday`: the ordinal of the Monday starting ISO week 1. */
|
|
394
|
+
function isoWeek1Monday(year) {
|
|
395
|
+
const firstDay = ymdToOrdinal(year, 1, 1);
|
|
396
|
+
const firstWeekday = (firstDay + 6) % 7;
|
|
397
|
+
return firstWeekday > 3 ? firstDay - firstWeekday + 7 : firstDay - firstWeekday;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** CPython `_isoweek_to_gregorian`: a week date as a calendar date, else null. */
|
|
401
|
+
function isoWeekToGregorian(year, week, day) {
|
|
402
|
+
// Bounded this way because 9999-12-31 is (9999, 52, 5).
|
|
403
|
+
if (year < MIN_ISO_YEAR || year > MAX_ISO_YEAR) return null;
|
|
404
|
+
if (week < 1 || week > 53) return null;
|
|
405
|
+
if (week === 53) {
|
|
406
|
+
// ISO years have 53 weeks when they start on a Thursday, and when a leap
|
|
407
|
+
// year starts on a Wednesday. Ordinal 1 (0001-01-01) is a Monday, so the
|
|
408
|
+
// ordinal modulo 7 is 1 for Monday, 3 for Wednesday, 4 for Thursday.
|
|
409
|
+
const firstWeekday = ymdToOrdinal(year, 1, 1) % 7;
|
|
410
|
+
if (!(firstWeekday === 4 || (firstWeekday === 3 && isLeapYear(year)))) return null;
|
|
411
|
+
}
|
|
412
|
+
if (day < 1 || day > 7) return null;
|
|
413
|
+
return ordinalToYmd(isoWeek1Monday(year) + (week - 1) * 7 + (day - 1));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* CPython `_find_isoformat_datetime_separator`: the index of the character
|
|
418
|
+
* between the date and the time; null where CPython raises.
|
|
419
|
+
*
|
|
420
|
+
* The character AT that index is never examined — `T`, a space, and any other
|
|
421
|
+
* single character all separate a date from a time, which is why this returns
|
|
422
|
+
* a position rather than matching a separator.
|
|
423
|
+
*/
|
|
424
|
+
function findIsoformatDatetimeSeparator(text) {
|
|
425
|
+
const length = text.length;
|
|
426
|
+
if (length === 7) return 7;
|
|
427
|
+
if (text[4] === "-") {
|
|
428
|
+
if (text[5] === "W") {
|
|
429
|
+
if (length > 8 && text[8] === "-") {
|
|
430
|
+
if (length === 9) return null;
|
|
431
|
+
// YYYY-Www-## is ambiguous; CPython resolves it toward the hyphen at 8
|
|
432
|
+
// and calls that best effort, so the port inherits the same guess.
|
|
433
|
+
if (length > 10 && isAsciiDigit(text.charCodeAt(10))) return 8;
|
|
434
|
+
return 10;
|
|
435
|
+
}
|
|
436
|
+
return 8; // YYYY-Www
|
|
437
|
+
}
|
|
438
|
+
return 10; // YYYY-MM-DD
|
|
439
|
+
}
|
|
440
|
+
if (text[4] === "W") {
|
|
441
|
+
// YYYYWww (7) or YYYYWwwd (8): the run of digits decides which.
|
|
442
|
+
let index = 7;
|
|
443
|
+
while (index < length && isAsciiDigit(text.charCodeAt(index))) index += 1;
|
|
444
|
+
if (index < 9) return index;
|
|
445
|
+
return index % 2 === 0 ? 7 : 8;
|
|
446
|
+
}
|
|
447
|
+
return 8; // YYYYMMDD
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** CPython `_parse_isoformat_date`: `{ year, month, day }`, else null. */
|
|
451
|
+
function parseIsoformatDate(text) {
|
|
452
|
+
// CPython asserts this; the C accelerator reports a parse failure instead,
|
|
453
|
+
// which is what null is here.
|
|
454
|
+
if (text.length !== 7 && text.length !== 8 && text.length !== 10) return null;
|
|
455
|
+
const year = parseDigits(text, 0, 4);
|
|
456
|
+
if (year === null) return null;
|
|
457
|
+
const hasSeparator = text[4] === "-";
|
|
458
|
+
let pos = 4 + (hasSeparator ? 1 : 0);
|
|
459
|
+
if (text[pos] === "W") {
|
|
460
|
+
pos += 1;
|
|
461
|
+
const week = parseDigits(text, pos, 2);
|
|
462
|
+
if (week === null) return null;
|
|
463
|
+
pos += 2;
|
|
464
|
+
let day = 1;
|
|
465
|
+
if (text.length > pos) {
|
|
466
|
+
if ((text[pos] === "-") !== hasSeparator) return null; // inconsistent dash
|
|
467
|
+
pos += hasSeparator ? 1 : 0;
|
|
468
|
+
day = parseDigits(text, pos, 1);
|
|
469
|
+
if (day === null) return null;
|
|
470
|
+
}
|
|
471
|
+
return isoWeekToGregorian(year, week, day);
|
|
472
|
+
}
|
|
473
|
+
const month = parseDigits(text, pos, 2);
|
|
474
|
+
if (month === null) return null;
|
|
475
|
+
pos += 2;
|
|
476
|
+
if ((text[pos] === "-") !== hasSeparator) return null; // inconsistent dash
|
|
477
|
+
pos += hasSeparator ? 1 : 0;
|
|
478
|
+
const day = parseDigits(text, pos, 2);
|
|
479
|
+
if (day === null) return null;
|
|
480
|
+
return { year, month, day };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* CPython `_parse_hh_mm_ss_ff`: `HH[:?MM[:?SS]][.,]f+` as
|
|
485
|
+
* `[hour, minute, second, microsecond]`, else null.
|
|
486
|
+
*
|
|
487
|
+
* The fraction is where the regex this replaces was wrong: CPython reads the
|
|
488
|
+
* first six digits, truncates the rest, and only requires that the truncated
|
|
489
|
+
* remainder BE digits — so a fraction of any length parses. Capping it at nine
|
|
490
|
+
* made `.0000010000Z` unparseable, and an unparseable `generated_at` is an
|
|
491
|
+
* artifact that silently leaves the staleness comparison.
|
|
492
|
+
*/
|
|
493
|
+
function parseHhMmSsFf(text) {
|
|
494
|
+
const length = text.length;
|
|
495
|
+
const comps = [0, 0, 0, 0];
|
|
496
|
+
let pos = 0;
|
|
497
|
+
let hasSeparator = false;
|
|
498
|
+
for (let comp = 0; comp < 3; comp += 1) {
|
|
499
|
+
if (length - pos < 2) return null; // incomplete time component
|
|
500
|
+
const value = parseDigits(text, pos, 2);
|
|
501
|
+
if (value === null) return null;
|
|
502
|
+
comps[comp] = value;
|
|
503
|
+
pos += 2;
|
|
504
|
+
const nextChar = text[pos] ?? "";
|
|
505
|
+
if (comp === 0) hasSeparator = nextChar === ":";
|
|
506
|
+
if (!nextChar || comp >= 2) break;
|
|
507
|
+
// A fraction ends the time components wherever they have got to, so `10.5`
|
|
508
|
+
// and `10:00.5` are half a second past the hour just as `10:00:00.5` is.
|
|
509
|
+
// Reading this position as a component separator instead refused both, and
|
|
510
|
+
// an unparseable generated_at drops its artifact out of the staleness
|
|
511
|
+
// comparison entirely — which is how a stale artifact reads as clean.
|
|
512
|
+
if (nextChar === "." || nextChar === ",") break;
|
|
513
|
+
if (hasSeparator && nextChar !== ":") return null; // invalid time separator
|
|
514
|
+
pos += hasSeparator ? 1 : 0;
|
|
515
|
+
}
|
|
516
|
+
if (pos < length) {
|
|
517
|
+
// ISO-8601 allows either fraction separator and CPython takes both.
|
|
518
|
+
if (text[pos] !== "." && text[pos] !== ",") return null;
|
|
519
|
+
pos += 1;
|
|
520
|
+
const remainder = length - pos;
|
|
521
|
+
if (remainder === 0) return null; // a separator with no fraction behind it
|
|
522
|
+
const parsed = Math.min(remainder, 6);
|
|
523
|
+
const fraction = parseDigits(text, pos, parsed);
|
|
524
|
+
if (fraction === null) return null;
|
|
525
|
+
comps[3] = parsed < 6 ? fraction * FRACTION_CORRECTION[parsed - 1] : fraction;
|
|
526
|
+
// Digits past the sixth are dropped rather than rounded, but they still
|
|
527
|
+
// have to be digits.
|
|
528
|
+
for (let index = pos + parsed; index < length; index += 1) {
|
|
529
|
+
if (!isAsciiDigit(text.charCodeAt(index))) return null;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return comps;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* CPython `_parse_isoformat_time`: the time of day and its UTC offset in whole
|
|
537
|
+
* microseconds, else null.
|
|
538
|
+
*
|
|
539
|
+
* The offset accepts `±HH`, `±HHMM`, `±HH:MM`, `±HHMMSS`, `±HH:MM:SS` and any
|
|
540
|
+
* of the last four with a fraction, and its magnitude must stay strictly under
|
|
541
|
+
* 24 hours — the bound `timezone()` enforces on the far side of the Python
|
|
542
|
+
* call, which is why `+25:00` is a refusal and not an offset. A trailing `Z`
|
|
543
|
+
* never reaches here: `parseIsoInstant` has already rewritten it to `+00:00`.
|
|
544
|
+
*/
|
|
545
|
+
function parseIsoformatTime(text) {
|
|
546
|
+
const length = text.length;
|
|
547
|
+
if (length < 2) return null;
|
|
548
|
+
// Where the offset starts, as CPython's
|
|
549
|
+
// `tz_pos = (tstr.find('-') + 1 or tstr.find('+') + 1 or tstr.find('Z') + 1)`
|
|
550
|
+
// actually behaves — written as an explicit scan, and with "absent" kept
|
|
551
|
+
// distinct from "at index 0" rather than riding on `+ 1` being falsy only for
|
|
552
|
+
// `-1`.
|
|
553
|
+
//
|
|
554
|
+
// Measured against the pure-Python `_parse_isoformat_time` in this machine's
|
|
555
|
+
// CPython 3.11.15: the chain picks by CHARACTER, not by position. A `-`
|
|
556
|
+
// anywhere wins over an earlier `+` or `Z` — `10:00+05-30` splits at the `-`
|
|
557
|
+
// and then refuses `10:00+05` as a time, and `10:00Z-05` refuses `10:00Z` the
|
|
558
|
+
// same way — and `Z` is consulted only when neither sign appears at all.
|
|
559
|
+
// (CPython's own comment there calls the chain equivalent to
|
|
560
|
+
// `re.search('[+-Z]', tstr)`, which would be the lowest position of the
|
|
561
|
+
// three; it is not what the chain does, and the rows in the ISO table measure
|
|
562
|
+
// the chain.) An offset character at index 0 leaves an empty time, which
|
|
563
|
+
// `_parse_hh_mm_ss_ff` refuses — the same refusal CPython gives `-10:00`.
|
|
564
|
+
let offsetAt = -1;
|
|
565
|
+
for (const marker of OFFSET_MARKERS) {
|
|
566
|
+
const at = text.indexOf(marker);
|
|
567
|
+
if (at !== -1) {
|
|
568
|
+
offsetAt = at;
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const comps = parseHhMmSsFf(offsetAt === -1 ? text : text.slice(0, offsetAt));
|
|
573
|
+
if (comps === null) return null;
|
|
574
|
+
let offsetMicros = 0n;
|
|
575
|
+
if (offsetAt !== -1) {
|
|
576
|
+
// CPython's `tz_pos == len_str and tstr[-1] == 'Z'` branch is not ported:
|
|
577
|
+
// `parseIsoInstant` rewrites a trailing `Z` to `+00:00` before this
|
|
578
|
+
// function ever sees the time, so by construction no fragment reaching here
|
|
579
|
+
// ends in `Z` and the branch could only ever have been dead code. `Z` stays
|
|
580
|
+
// in the scan above because CPython splits on an EMBEDDED one and this
|
|
581
|
+
// follows it there, sign included: `10:00Z05` reads as `10:00+05:00`,
|
|
582
|
+
// exactly as the pure-Python reference does (the C accelerator refuses it —
|
|
583
|
+
// a divergence this port has always had, unrelated to the scan).
|
|
584
|
+
const tzText = text.slice(offsetAt + 1);
|
|
585
|
+
// Valid offset lengths are 2, 4, 5, 6, 7+, 8 and 10+; 0, 1 and 3 are not.
|
|
586
|
+
if (tzText.length === 0 || tzText.length === 1 || tzText.length === 3) return null;
|
|
587
|
+
const tzComps = parseHhMmSsFf(tzText);
|
|
588
|
+
if (tzComps === null) return null;
|
|
589
|
+
// The C accelerator's UTC special case: it converts the offset to WHOLE
|
|
590
|
+
// seconds and returns UTC when that is zero, so a sub-second-only offset
|
|
591
|
+
// like `+00:00:00.5` is UTC and its fraction is discarded — while an offset
|
|
592
|
+
// whose whole-second part is non-zero keeps its fraction (`+00:00:01.5` is
|
|
593
|
+
// one and a half seconds). Carrying the discarded half-second reordered two
|
|
594
|
+
// packets recorded half a second apart, and packet order decides which run
|
|
595
|
+
// the readback projects.
|
|
596
|
+
const wholeSeconds = BigInt(tzComps[0]) * 3600n + BigInt(tzComps[1]) * 60n + BigInt(tzComps[2]);
|
|
597
|
+
if (wholeSeconds !== 0n) {
|
|
598
|
+
const magnitude = wholeSeconds * MICROS_PER_SECOND + BigInt(tzComps[3]);
|
|
599
|
+
if (magnitude > MAX_OFFSET_MICROS) return null;
|
|
600
|
+
offsetMicros = text[offsetAt] === "-" ? -magnitude : magnitude;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return { hour: comps[0], minute: comps[1], second: comps[2], micros: comps[3], offsetMicros };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Parse an artifact's ISO-8601 timestamp into an instant; null when unparseable.
|
|
608
|
+
*
|
|
609
|
+
* Ports `_parse_iso_timestamp`, which rewrites a trailing `Z` to `+00:00` and
|
|
610
|
+
* hands the result to `datetime.fromisoformat`; the helpers above are that
|
|
611
|
+
* function's grammar and this is its body.
|
|
612
|
+
*
|
|
613
|
+
* Returns `{ date, micros }`: a millisecond `Date`, which is what every
|
|
614
|
+
* rendering path formats, and the same instant in whole microseconds since the
|
|
615
|
+
* epoch as a BigInt, which is what every comparison and ordering path uses.
|
|
616
|
+
* The two are separate because JavaScript's Date cannot hold sub-millisecond
|
|
617
|
+
* precision at all, and truncating to it silently made two packets a
|
|
618
|
+
* microsecond apart a tie — a refusal to choose, exit 2, over a difference the
|
|
619
|
+
* artifacts had recorded. The Python readback this module ports compares with
|
|
620
|
+
* `datetime.fromisoformat`, which keeps microseconds, so the truncation was a
|
|
621
|
+
* parity defect as well as a defect on its own terms. Precision stops at six
|
|
622
|
+
* fractional digits, and a seventh or later digit is truncated rather than
|
|
623
|
+
* rounded, which is what `fromisoformat` does with the same input.
|
|
624
|
+
*
|
|
625
|
+
* A value without a timezone is assumed UTC. Campaigns OS emits `generated_at`
|
|
626
|
+
* with a Z suffix, so the assumption is documentation for hand-authored
|
|
627
|
+
* artifacts, not a branch the emitted format exercises.
|
|
628
|
+
*/
|
|
629
|
+
export function parseIsoInstant(value) {
|
|
630
|
+
if (typeof value !== "string" || !value) return null;
|
|
631
|
+
const text = value.endsWith("Z") ? `${value.slice(0, -1)}+00:00` : value;
|
|
632
|
+
if (text.length < 7) return null;
|
|
633
|
+
const separator = findIsoformatDatetimeSeparator(text);
|
|
634
|
+
if (separator === null) return null;
|
|
635
|
+
const date = parseIsoformatDate(text.slice(0, separator));
|
|
636
|
+
if (date === null) return null;
|
|
637
|
+
let time;
|
|
638
|
+
if (separator >= text.length) {
|
|
639
|
+
// A bare date, with no separator character at all: midnight.
|
|
640
|
+
time = { hour: 0, minute: 0, second: 0, micros: 0, offsetMicros: 0n };
|
|
641
|
+
} else {
|
|
642
|
+
// The separator is one Unicode character, which may be a surrogate pair —
|
|
643
|
+
// skipping a single UTF-16 code unit left its trailing half at the head of
|
|
644
|
+
// the time string and made an otherwise valid timestamp unparseable.
|
|
645
|
+
const separatorLength = text.codePointAt(separator) > 0xffff ? 2 : 1;
|
|
646
|
+
const tail = text.slice(separator + separatorLength);
|
|
647
|
+
// A separator with nothing behind it is malformed, not midnight: the run
|
|
648
|
+
// wrote a date and started a time it never finished, and reading that as
|
|
649
|
+
// 00:00:00 invents an instant no artifact recorded — one that beats every
|
|
650
|
+
// real timestamp from the day before. Unparseable is the honest answer, and
|
|
651
|
+
// for packet discovery it is the unknown candidate that forces a refusal.
|
|
652
|
+
if (!tail) return null;
|
|
653
|
+
time = parseIsoformatTime(tail);
|
|
654
|
+
}
|
|
655
|
+
if (time === null) return null;
|
|
656
|
+
// The `datetime` constructor's own range checks, which run after parsing and
|
|
657
|
+
// raise the same ValueError the caller reads as "unparseable".
|
|
658
|
+
if (date.year < MIN_ISO_YEAR || date.year > MAX_ISO_YEAR) return null;
|
|
659
|
+
if (date.month < 1 || date.month > 12) return null;
|
|
660
|
+
if (date.day < 1 || date.day > daysInMonth(date.year, date.month)) return null;
|
|
661
|
+
if (time.hour > 23 || time.minute > 59 || time.second > 59) return null;
|
|
662
|
+
const stamp = utcMillis(date.year, date.month, date.day, time.hour, time.minute, time.second);
|
|
663
|
+
if (!Number.isFinite(stamp)) return null;
|
|
664
|
+
const micros = BigInt(stamp) * 1000n + BigInt(time.micros) - time.offsetMicros;
|
|
665
|
+
// Floor rather than truncate toward zero so the rendered second of a
|
|
666
|
+
// pre-epoch instant is the second it falls in, not the one after it.
|
|
667
|
+
const millis = micros / 1000n - (micros % 1000n < 0n ? 1n : 0n);
|
|
668
|
+
return { date: new Date(Number(millis)), micros };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Parse an artifact's ISO-8601 timestamp as a Date; null when unparseable.
|
|
673
|
+
*
|
|
674
|
+
* The millisecond half of parseIsoInstant, kept for callers that only render.
|
|
675
|
+
* Anything that compares or orders two timestamps must use parseIsoInstant:
|
|
676
|
+
* this Date cannot distinguish two artifacts less than a millisecond apart.
|
|
677
|
+
*/
|
|
678
|
+
export function parseIsoTimestamp(value) {
|
|
679
|
+
return parseIsoInstant(value)?.date ?? null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Render one instant as the readback's single timestamp format. */
|
|
683
|
+
export function formatUtc(value) {
|
|
684
|
+
return `${value.toISOString().slice(0, 19)}Z`;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function statOrNull(path) {
|
|
688
|
+
try {
|
|
689
|
+
return statSync(path, { throwIfNoEntry: false }) ?? null;
|
|
690
|
+
} catch {
|
|
691
|
+
// An unreadable ancestor is "no .git here", the same answer a missing one
|
|
692
|
+
// gives; the walk continues upward rather than failing the projection.
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Find the nearest `.git` entry at root or one of its ancestors.
|
|
699
|
+
*
|
|
700
|
+
* Funnel targets are usually subdirectories of their enclosing campaign
|
|
701
|
+
* repository, so Git metadata rarely sits at the target itself. Walks upward
|
|
702
|
+
* from the target and stops at the first `.git` entry (directory or worktree
|
|
703
|
+
* pointer file). File-metadata checks only.
|
|
704
|
+
*/
|
|
705
|
+
export function discoverGitEntry(root) {
|
|
706
|
+
let current = resolve(root);
|
|
707
|
+
for (;;) {
|
|
708
|
+
const gitEntry = join(current, ".git");
|
|
709
|
+
const info = statOrNull(gitEntry);
|
|
710
|
+
if (info && (info.isFile() || info.isDirectory())) {
|
|
711
|
+
return { gitEntry, containingDir: current, isFile: info.isFile() };
|
|
712
|
+
}
|
|
713
|
+
const parent = resolve(current, "..");
|
|
714
|
+
if (parent === current) return { gitEntry: null, containingDir: null, isFile: false };
|
|
715
|
+
current = parent;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* When the enclosing checkout's HEAD last moved, from the reflog.
|
|
721
|
+
*
|
|
722
|
+
* Returns `{ time, detail }`: an instant and an empty detail on success, or a
|
|
723
|
+
* null time and a reason when the signal is unavailable. The reflog's last
|
|
724
|
+
* entry advances on commit, checkout, pull, and reset alike; any of those can
|
|
725
|
+
* invalidate previously emitted artifacts, so "HEAD last moved" is deliberately
|
|
726
|
+
* the coarsest local signal, not "last commit authored". The checkout is the
|
|
727
|
+
* nearest `.git` entry at the target or an ancestor. Reading `.git` and the
|
|
728
|
+
* reflog keeps the module's no-process contract; nothing shells out to git.
|
|
729
|
+
*/
|
|
730
|
+
export function readHeadMovement(root) {
|
|
731
|
+
const { gitEntry, containingDir, isFile } = discoverGitEntry(root);
|
|
732
|
+
if (gitEntry === null) {
|
|
733
|
+
return { time: null, detail: "the target root is not a Git checkout and no ancestor contains .git" };
|
|
734
|
+
}
|
|
735
|
+
let gitDir = gitEntry;
|
|
736
|
+
if (isFile) {
|
|
737
|
+
let pointerText;
|
|
738
|
+
try {
|
|
739
|
+
pointerText = readBoundedText(gitEntry, MAX_GIT_METADATA_BYTES);
|
|
740
|
+
} catch (error) {
|
|
741
|
+
return { time: null, detail: `could not read the .git pointer file (${error.code ?? error.name})` };
|
|
742
|
+
}
|
|
743
|
+
gitDir = null;
|
|
744
|
+
for (const line of pointerText.split(/\r?\n/)) {
|
|
745
|
+
if (!line.startsWith("gitdir:")) continue;
|
|
746
|
+
const candidate = line.slice("gitdir:".length).trim();
|
|
747
|
+
// A relative gitdir resolves against the .git-bearing ancestor, not the
|
|
748
|
+
// nested target: were the base the target, a sibling worktree pointer
|
|
749
|
+
// would resolve under the funnel directory and the signal would go dark.
|
|
750
|
+
gitDir = isAbsolute(candidate) ? candidate : join(containingDir, candidate);
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
if (gitDir === null) return { time: null, detail: "the .git file carries no gitdir pointer" };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
let raw;
|
|
757
|
+
try {
|
|
758
|
+
raw = readReflogTail(join(gitDir, "logs", "HEAD"));
|
|
759
|
+
} catch (error) {
|
|
760
|
+
if (error?.code === "ENOENT") return { time: null, detail: "the Git checkout has no HEAD reflog" };
|
|
761
|
+
if (error instanceof ReflogTailError) {
|
|
762
|
+
return { time: null, detail: `could not read the HEAD reflog (${error.message})` };
|
|
763
|
+
}
|
|
764
|
+
return { time: null, detail: `could not read the HEAD reflog (${error.code ?? error.name})` };
|
|
765
|
+
}
|
|
766
|
+
const entries = raw.split(/\r?\n/).filter((line) => line.trim());
|
|
767
|
+
if (!entries.length) return { time: null, detail: "the HEAD reflog is empty" };
|
|
768
|
+
const identity = entries[entries.length - 1].split("\t")[0];
|
|
769
|
+
const words = identity.split(" ");
|
|
770
|
+
if (words.length < 3) return { time: null, detail: "the last HEAD reflog entry is not in reflog format" };
|
|
771
|
+
const epochText = words[words.length - 2];
|
|
772
|
+
if (!/^[+-]?\d+$/.test(epochText)) {
|
|
773
|
+
return { time: null, detail: "the last HEAD reflog entry carries no epoch timestamp" };
|
|
774
|
+
}
|
|
775
|
+
const epoch = BigInt(epochText);
|
|
776
|
+
if (epoch < BigInt(MIN_EPOCH_SECONDS) || epoch > BigInt(MAX_EPOCH_SECONDS)) {
|
|
777
|
+
return { time: null, detail: "the last HEAD reflog entry's timestamp is out of range" };
|
|
778
|
+
}
|
|
779
|
+
return { time: new Date(Number(epoch) * 1000), detail: "" };
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Compare EVERY loaded artifact's generated_at against the HEAD reflog.
|
|
784
|
+
*
|
|
785
|
+
* Part of the readback's own projection layer. Deterministic: the result is a
|
|
786
|
+
* pure function of the artifact contents and two Git metadata files; no wall
|
|
787
|
+
* clock is consulted.
|
|
788
|
+
*
|
|
789
|
+
* Per-artifact by design (the v1 defect this port fixes). v1 compared only the
|
|
790
|
+
* newest loaded artifact, so one freshly regenerated artifact reported the
|
|
791
|
+
* whole set fresh while its siblings predated the same HEAD movement. Each
|
|
792
|
+
* artifact now carries its own verdict, and the aggregate `stale` is true when
|
|
793
|
+
* any of them is stale. An artifact whose `generated_at` this readback cannot
|
|
794
|
+
* parse is neither fresh nor stale: it stays out of the map, and if it is the
|
|
795
|
+
* only artifact the assessment is not computable.
|
|
796
|
+
*
|
|
797
|
+
* Two kinds of missing age are kept apart, because they say different things
|
|
798
|
+
* about the run. An artifact that RECORDED a `generated_at` this parser refuses
|
|
799
|
+
* has an age the readback failed to establish — `unparseable_keys` names it,
|
|
800
|
+
* `unparseable_details` describes the value's shape, and `computeClean` reads
|
|
801
|
+
* the list, because "unknown age" must never render as "not older than the
|
|
802
|
+
* checkout". An artifact with no `generated_at` key at all recorded no age to
|
|
803
|
+
* establish: it is simply absent from the comparison, as it has always been,
|
|
804
|
+
* and is not by itself unclean.
|
|
805
|
+
*
|
|
806
|
+
* `headMovement` overrides the Git read for a caller that already knows the
|
|
807
|
+
* answer — `--example` projects a packaged fixture directory, which is not a
|
|
808
|
+
* checkout and must say so identically wherever the package is installed.
|
|
809
|
+
*/
|
|
810
|
+
export function assessStaleness(root, views, { headMovement = null } = {}) {
|
|
811
|
+
const artifactTimes = {};
|
|
812
|
+
// Comparison and ordering run on microseconds, never on the rendered Dates:
|
|
813
|
+
// two artifacts under a millisecond apart are two instants, not one.
|
|
814
|
+
const artifactMicros = {};
|
|
815
|
+
const loadedKeys = [];
|
|
816
|
+
const unparseableKeys = [];
|
|
817
|
+
const unparseableDetails = {};
|
|
818
|
+
for (const [key, view] of Object.entries(views)) {
|
|
819
|
+
if (view.state !== "loaded") continue;
|
|
820
|
+
loadedKeys.push(key);
|
|
821
|
+
const data = isPlainObject(view.data) ? view.data : {};
|
|
822
|
+
const parsed = parseIsoInstant(data.generated_at);
|
|
823
|
+
if (parsed !== null) {
|
|
824
|
+
artifactTimes[key] = parsed.date;
|
|
825
|
+
artifactMicros[key] = parsed.micros;
|
|
826
|
+
} else if ("generated_at" in data) {
|
|
827
|
+
// The key is present and its value did not parse: the artifact claims an
|
|
828
|
+
// age this readback could not read. Recorded by key rather than dropped,
|
|
829
|
+
// so the projection can say so instead of quietly comparing the rest.
|
|
830
|
+
unparseableKeys.push(key);
|
|
831
|
+
unparseableDetails[key] = describeUnparseableAge(data.generated_at);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
const { time: headTime, detail: headDetail } = headMovement ?? readHeadMovement(root);
|
|
835
|
+
// The reflog records whole seconds, so the head instant is exact in
|
|
836
|
+
// milliseconds and scaling it loses nothing: this comparison is unchanged by
|
|
837
|
+
// the artifact side's added precision.
|
|
838
|
+
const headMicros = headTime === null ? null : BigInt(headTime.getTime()) * 1000n;
|
|
839
|
+
const keys = Object.keys(artifactTimes);
|
|
840
|
+
const artifacts = {};
|
|
841
|
+
const staleKeys = [];
|
|
842
|
+
for (const key of keys) {
|
|
843
|
+
// Absence of the HEAD signal is never evidence of freshness, but it is not
|
|
844
|
+
// evidence of staleness either: with no comparison point nothing is stale.
|
|
845
|
+
const stale = headMicros !== null && headMicros > artifactMicros[key];
|
|
846
|
+
artifacts[key] = { generated_at: artifactTimes[key], stale };
|
|
847
|
+
if (stale) staleKeys.push(key);
|
|
848
|
+
}
|
|
849
|
+
let newestKey = null;
|
|
850
|
+
for (const key of keys) {
|
|
851
|
+
if (newestKey === null || artifactMicros[key] > artifactMicros[newestKey]) newestKey = key;
|
|
852
|
+
}
|
|
853
|
+
const computable = keys.length > 0 && headTime !== null;
|
|
854
|
+
return {
|
|
855
|
+
artifact_times: artifactTimes,
|
|
856
|
+
artifacts,
|
|
857
|
+
stale_keys: staleKeys,
|
|
858
|
+
// The loaded artifacts, and the ones whose recorded age did not parse, both
|
|
859
|
+
// in render order. `loaded_keys` is what the rendered counts are drawn from
|
|
860
|
+
// — a sentence about "every loaded artifact" must not count the comparable
|
|
861
|
+
// ones — and `unparseable_details` carries each refused value's shape for
|
|
862
|
+
// the artifact row that reports it. Neither is serialized: the JSON payload
|
|
863
|
+
// names the artifacts through `unparseable_keys` and their states through
|
|
864
|
+
// the artifact rows it already carries.
|
|
865
|
+
loaded_keys: loadedKeys,
|
|
866
|
+
unparseable_keys: unparseableKeys,
|
|
867
|
+
unparseable_details: unparseableDetails,
|
|
868
|
+
head_time: headTime,
|
|
869
|
+
head_detail: headDetail,
|
|
870
|
+
computable,
|
|
871
|
+
stale: computable && staleKeys.length > 0,
|
|
872
|
+
newest_key: computable ? newestKey : null,
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Name the SHAPE of a `generated_at` this readback could not parse.
|
|
878
|
+
*
|
|
879
|
+
* The value itself is not rendered: a hand-edited or foreign artifact can carry
|
|
880
|
+
* an arbitrarily long string there, and the projection's line-bounded sections
|
|
881
|
+
* are not the place to reproduce it. The shape is enough for a reader to tell a
|
|
882
|
+
* mistyped timestamp from a value of the wrong type, and the artifact's own file
|
|
883
|
+
* is one read away for the rest.
|
|
884
|
+
*/
|
|
885
|
+
function describeUnparseableAge(value) {
|
|
886
|
+
if (value === null) return "null";
|
|
887
|
+
if (Array.isArray(value)) return `an array of ${value.length} item(s)`;
|
|
888
|
+
if (typeof value === "object") return "an object";
|
|
889
|
+
if (typeof value === "string") {
|
|
890
|
+
return value.length
|
|
891
|
+
? `a ${value.length}-character string that is not an ISO-8601 instant`
|
|
892
|
+
: "an empty string";
|
|
893
|
+
}
|
|
894
|
+
return `a ${typeof value}`;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Name the loaded artifacts whose recorded age did not parse.
|
|
899
|
+
*
|
|
900
|
+
* Rendered under both the computable and the not-computable branch: an artifact
|
|
901
|
+
* whose age was never established is the same fact either way, and it is the
|
|
902
|
+
* one the reader would otherwise have to infer from an artifact's absence from
|
|
903
|
+
* the sentences above.
|
|
904
|
+
*/
|
|
905
|
+
function renderUnknownAges(staleness, lines) {
|
|
906
|
+
const keys = staleness.unparseable_keys;
|
|
907
|
+
if (!keys.length) return;
|
|
908
|
+
lines.push(" *** UNKNOWN ARTIFACT AGE ***");
|
|
909
|
+
lines.push(
|
|
910
|
+
` ${keys.length} loaded artifact(s) recorded a generated_at this readback cannot read, so`,
|
|
911
|
+
);
|
|
912
|
+
lines.push(" nothing above shows whether they predate the checkout:");
|
|
913
|
+
for (const key of keys) {
|
|
914
|
+
lines.push(` ${ARTIFACT_TITLES[key]} (generated_at is ${staleness.unparseable_details[key]})`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function renderStaleness(staleness, lines) {
|
|
919
|
+
if (!staleness) return;
|
|
920
|
+
lines.push(
|
|
921
|
+
"STALENESS [the readback's own projection layer: EACH loaded artifact's " +
|
|
922
|
+
"generated_at versus the checkout's HEAD reflog]",
|
|
923
|
+
);
|
|
924
|
+
// Loaded and compared are two counts, and the sentences below say which is
|
|
925
|
+
// which. Rendering the comparable count as the loaded one asserted something
|
|
926
|
+
// about artifacts the comparison never examined.
|
|
927
|
+
const loadedCount = staleness.loaded_keys.length;
|
|
928
|
+
const comparedCount = Object.keys(staleness.artifacts).length;
|
|
929
|
+
if (staleness.computable) {
|
|
930
|
+
const headText = formatUtc(staleness.head_time);
|
|
931
|
+
if (staleness.stale) {
|
|
932
|
+
lines.push(" *** STALE ARTIFACTS ***");
|
|
933
|
+
lines.push(
|
|
934
|
+
` this checkout's HEAD last moved ${headText}; of ${loadedCount} loaded artifact(s), ` +
|
|
935
|
+
`${comparedCount} could be compared and ${staleness.stale_keys.length} predate it:`,
|
|
936
|
+
);
|
|
937
|
+
for (const key of staleness.stale_keys) {
|
|
938
|
+
lines.push(` ${ARTIFACT_TITLES[key]} (generated ${formatUtc(staleness.artifacts[key].generated_at)})`);
|
|
939
|
+
}
|
|
940
|
+
lines.push(" Every section below describes the repository as it was when the artifacts");
|
|
941
|
+
lines.push(" were generated, not necessarily as it is now; a new run must regenerate");
|
|
942
|
+
lines.push(" them before this view is current.");
|
|
943
|
+
} else {
|
|
944
|
+
lines.push(
|
|
945
|
+
` of ${loadedCount} loaded artifact(s), ${comparedCount} could be compared, and none of ` +
|
|
946
|
+
`those is older than the last recorded HEAD movement (${headText}).`,
|
|
947
|
+
);
|
|
948
|
+
const newestKey = staleness.newest_key;
|
|
949
|
+
lines.push(
|
|
950
|
+
` newest: ${ARTIFACT_TITLES[newestKey]}, generated ` +
|
|
951
|
+
`${formatUtc(staleness.artifacts[newestKey].generated_at)}.`,
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
} else {
|
|
955
|
+
const reasons = [];
|
|
956
|
+
if (staleness.head_time === null) reasons.push(staleness.head_detail);
|
|
957
|
+
if (!Object.keys(staleness.artifact_times).length) {
|
|
958
|
+
reasons.push("no loaded artifact carries a parseable generated_at");
|
|
959
|
+
}
|
|
960
|
+
lines.push(` not computable: ${reasons.join("; ")}.`);
|
|
961
|
+
lines.push(" Treat artifact age as unknown; check the artifacts' generated_at values");
|
|
962
|
+
lines.push(" against repository history before reading this view as current.");
|
|
963
|
+
}
|
|
964
|
+
renderUnknownAges(staleness, lines);
|
|
965
|
+
lines.push("");
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Label one doctor warning code; part of the readback's own layer. */
|
|
969
|
+
export function classifyDoctorWarning(code) {
|
|
970
|
+
if (typeof code === "string" && CONTRACT_STATIC_CODE_PREFIXES.some((prefix) => code.startsWith(prefix))) {
|
|
971
|
+
return "contract-static";
|
|
972
|
+
}
|
|
973
|
+
return "repo-observed";
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
function isPlainObject(value) {
|
|
977
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// A field an artifact did not record renders as a named absence rather than as
|
|
981
|
+
// the language's word for nothing: "status: undefined" reads like a defect in
|
|
982
|
+
// the readback, and the Python original's "status: None" read no better.
|
|
983
|
+
function recorded(value, fallback = "(not recorded)") {
|
|
984
|
+
return value === undefined || value === null ? fallback : value;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** Load one artifact into a view object; never throws for file problems. */
|
|
988
|
+
export function loadArtifact(key, path, { maxBytes = MAX_ARTIFACT_BYTES } = {}) {
|
|
989
|
+
const view = { key, path: String(path), state: "loaded", detail: "", data: null };
|
|
990
|
+
let raw;
|
|
991
|
+
try {
|
|
992
|
+
raw = readBoundedText(view.path, maxBytes);
|
|
993
|
+
} catch (error) {
|
|
994
|
+
if (error?.code === "ENOENT") {
|
|
995
|
+
view.state = "absent";
|
|
996
|
+
return view;
|
|
997
|
+
}
|
|
998
|
+
view.state = "unreadable";
|
|
999
|
+
view.detail = `${error.name}: ${error.message}`;
|
|
1000
|
+
return view;
|
|
1001
|
+
}
|
|
1002
|
+
let data;
|
|
1003
|
+
try {
|
|
1004
|
+
data = JSON.parse(raw);
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
view.state = "unreadable";
|
|
1007
|
+
view.detail = `invalid JSON: ${error.message}`;
|
|
1008
|
+
return view;
|
|
1009
|
+
}
|
|
1010
|
+
if (!isPlainObject(data)) {
|
|
1011
|
+
view.state = "unrecognized";
|
|
1012
|
+
view.detail = "top-level JSON value is not an object";
|
|
1013
|
+
return view;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const recognized = RECOGNIZED_SCHEMA_VERSIONS[key];
|
|
1017
|
+
if (recognized !== undefined) {
|
|
1018
|
+
const declared = data.schema_version;
|
|
1019
|
+
if (!recognized.includes(declared)) {
|
|
1020
|
+
view.state = "unrecognized";
|
|
1021
|
+
view.detail =
|
|
1022
|
+
`unrecognized schema_version ${JSON.stringify(declared ?? null)}; ` +
|
|
1023
|
+
`this readback projects ${recognized.join(" or ")}`;
|
|
1024
|
+
return view;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if (key === "doctor" && !(typeof data.status === "string" && Array.isArray(data.warnings))) {
|
|
1028
|
+
view.state = "unrecognized";
|
|
1029
|
+
view.detail = "doctor output must carry a status string and a warnings list";
|
|
1030
|
+
return view;
|
|
1031
|
+
}
|
|
1032
|
+
if (key === "qa_verdict" && !(typeof data.disposition === "string" && Array.isArray(data.assertions))) {
|
|
1033
|
+
view.state = "unrecognized";
|
|
1034
|
+
view.detail = "QA verdict must carry a disposition and an assertions list";
|
|
1035
|
+
return view;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
view.data = data;
|
|
1039
|
+
return view;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Load every artifact path in the fixed render order.
|
|
1044
|
+
*
|
|
1045
|
+
* `preloaded` supplies views already loaded from those same paths — Build
|
|
1046
|
+
* Packet discovery reads its candidates, so the chosen one would otherwise be
|
|
1047
|
+
* read twice. A preloaded view is used only when it names the path this call
|
|
1048
|
+
* would have read; anything else is loaded here.
|
|
1049
|
+
*/
|
|
1050
|
+
export function loadArtifacts(paths, preloaded = {}) {
|
|
1051
|
+
const views = {};
|
|
1052
|
+
for (const key of Object.keys(DEFAULT_RELATIVE_PATHS)) {
|
|
1053
|
+
if (!(key in paths)) continue;
|
|
1054
|
+
const path = String(paths[key]);
|
|
1055
|
+
const candidate = preloaded?.[key] ?? null;
|
|
1056
|
+
views[key] = candidate !== null && candidate.path === path ? candidate : loadArtifact(key, path);
|
|
1057
|
+
}
|
|
1058
|
+
return views;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function artifactData(views, key) {
|
|
1062
|
+
const view = views[key];
|
|
1063
|
+
if (view === undefined || view.state !== "loaded") return null;
|
|
1064
|
+
return view.data;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* Bucket the QA verdict's assertions by recorded status.
|
|
1069
|
+
*
|
|
1070
|
+
* `other` collects any status this readback does not project (it renders those
|
|
1071
|
+
* rows as written rather than reclassifying them). Non-object entries are
|
|
1072
|
+
* dropped: an assertion the readback cannot address by status is not an
|
|
1073
|
+
* assertion it can project. Returns empty buckets when no QA verdict loaded, so
|
|
1074
|
+
* every caller can treat "no verdict" as "no failures" without a separate
|
|
1075
|
+
* branch.
|
|
1076
|
+
*/
|
|
1077
|
+
export function partitionAssertions(views) {
|
|
1078
|
+
const buckets = { fail: [], pass: [], skipped: [], other: [] };
|
|
1079
|
+
const verdict = artifactData(views, "qa_verdict");
|
|
1080
|
+
if (verdict === null) return buckets;
|
|
1081
|
+
for (const assertion of verdict.assertions ?? []) {
|
|
1082
|
+
if (!isPlainObject(assertion)) continue;
|
|
1083
|
+
const status = assertion.status;
|
|
1084
|
+
const bucket = status === "fail" || status === "pass" || status === "skipped" ? status : "other";
|
|
1085
|
+
buckets[bucket].push(assertion);
|
|
1086
|
+
}
|
|
1087
|
+
return buckets;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Group skipped verdict families by the failure that blocked them.
|
|
1092
|
+
*
|
|
1093
|
+
* Part of the readback's own projection layer: the grouping is derived from
|
|
1094
|
+
* each skipped assertion's recorded `blocked_by`, so a blocked verdict reads as
|
|
1095
|
+
* one cascade rather than a dozen independent skips. Returns
|
|
1096
|
+
* `{ blocked_by, families }` records in the order the blockers were first seen,
|
|
1097
|
+
* which is the order the text view renders.
|
|
1098
|
+
*/
|
|
1099
|
+
export function computeSkipCascades(views) {
|
|
1100
|
+
const cascades = new Map();
|
|
1101
|
+
for (const assertion of partitionAssertions(views).skipped) {
|
|
1102
|
+
const evidence = assertion.evidence;
|
|
1103
|
+
const blocker = (isPlainObject(evidence) ? evidence.blocked_by : null) || "(no blocked_by recorded)";
|
|
1104
|
+
if (!cascades.has(blocker)) cascades.set(blocker, []);
|
|
1105
|
+
cascades.get(blocker).push(assertion.family || assertion.id || "(unnamed)");
|
|
1106
|
+
}
|
|
1107
|
+
return [...cascades].map(([blocked_by, families]) => ({ blocked_by, families }));
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Find where two artifacts record different states for one stage.
|
|
1112
|
+
*
|
|
1113
|
+
* Part of the readback's own projection layer, and deliberately not an
|
|
1114
|
+
* adjudication: a divergence says the assembly report calls a stage completed
|
|
1115
|
+
* while the QA verdict fails an assertion named for that stage. Returns
|
|
1116
|
+
* `{ stage, assertion_ids }` records, one per stage, in the order the failures
|
|
1117
|
+
* were seen.
|
|
1118
|
+
*/
|
|
1119
|
+
export function computeDivergences(views) {
|
|
1120
|
+
const report = artifactData(views, "report");
|
|
1121
|
+
const failed = partitionAssertions(views).fail;
|
|
1122
|
+
if (report === null || !failed.length) return [];
|
|
1123
|
+
const stages = report.stages;
|
|
1124
|
+
if (!isPlainObject(stages)) return [];
|
|
1125
|
+
const divergent = new Map();
|
|
1126
|
+
for (const assertion of failed) {
|
|
1127
|
+
const identifier = assertion.id;
|
|
1128
|
+
if (typeof identifier !== "string" || !identifier.includes(".")) continue;
|
|
1129
|
+
const stageName = identifier.slice(0, identifier.indexOf("."));
|
|
1130
|
+
const stage = stages[stageName];
|
|
1131
|
+
if (isPlainObject(stage) && stage.status === "completed") {
|
|
1132
|
+
if (!divergent.has(stageName)) divergent.set(stageName, []);
|
|
1133
|
+
divergent.get(stageName).push(identifier);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return [...divergent].map(([stage, assertion_ids]) => ({ stage, assertion_ids }));
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Say how the projected Build Packet was chosen, when that is not obvious.
|
|
1141
|
+
*
|
|
1142
|
+
* Silent for the ordinary target — one packet, at the default name, nothing
|
|
1143
|
+
* ignored — and explicit whenever discovery had more than one file in front of
|
|
1144
|
+
* it, so an operator can see which packet this view describes.
|
|
1145
|
+
*/
|
|
1146
|
+
function renderPacketSelection(selection, lines) {
|
|
1147
|
+
if (!selection) return;
|
|
1148
|
+
const indent = ` ${"".padEnd(16)} `;
|
|
1149
|
+
const candidates = selection.candidates_considered;
|
|
1150
|
+
if (candidates.length > 1) {
|
|
1151
|
+
lines.push(
|
|
1152
|
+
`${indent}chose ${selection.selected} by generated_at from ${candidates.length} ` +
|
|
1153
|
+
`root-level Build Packet candidate(s): ${candidates.join(", ")}`,
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
for (const entry of selection.rejected) {
|
|
1157
|
+
lines.push(`${indent}ignored candidate ${entry.path}: ${entry.reason}`);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function renderArtifactTable(views, lines, packetSelection = null) {
|
|
1162
|
+
lines.push("ARTIFACTS");
|
|
1163
|
+
for (const [key, view] of Object.entries(views)) {
|
|
1164
|
+
const title = ARTIFACT_TITLES[key];
|
|
1165
|
+
let status;
|
|
1166
|
+
if (view.state === "loaded") {
|
|
1167
|
+
const generated = (view.data || {}).generated_at;
|
|
1168
|
+
status = typeof generated === "string" && generated ? `loaded — generated_at ${generated}` : "loaded";
|
|
1169
|
+
} else if (view.state === "absent") {
|
|
1170
|
+
status = "absent";
|
|
1171
|
+
} else {
|
|
1172
|
+
status = `${view.state} — ${view.detail}`;
|
|
1173
|
+
}
|
|
1174
|
+
lines.push(` ${title.padEnd(16)} ${view.path}`);
|
|
1175
|
+
lines.push(` ${"".padEnd(16)} ${status}`);
|
|
1176
|
+
if (key === "packet") renderPacketSelection(packetSelection, lines);
|
|
1177
|
+
}
|
|
1178
|
+
lines.push("");
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function renderIdentity(views, lines) {
|
|
1182
|
+
const entries = [];
|
|
1183
|
+
const packet = artifactData(views, "packet");
|
|
1184
|
+
const doctor = artifactData(views, "doctor");
|
|
1185
|
+
const verdict = artifactData(views, "qa_verdict");
|
|
1186
|
+
if (packet) {
|
|
1187
|
+
const spec = packet.spec || {};
|
|
1188
|
+
const campaign = packet.campaign || {};
|
|
1189
|
+
if (spec.map_id) entries.push(["map_id", spec.map_id, "build packet"]);
|
|
1190
|
+
if (campaign.public_route_slug) {
|
|
1191
|
+
entries.push(["public_route_slug", campaign.public_route_slug, "build packet"]);
|
|
1192
|
+
}
|
|
1193
|
+
const assembly = packet.assembly || {};
|
|
1194
|
+
if (assembly.template_family) entries.push(["template_family", assembly.template_family, "build packet"]);
|
|
1195
|
+
} else if (doctor) {
|
|
1196
|
+
const derived = doctor.derived || {};
|
|
1197
|
+
for (const field of ["map_id", "public_route_slug", "template_family"]) {
|
|
1198
|
+
if (derived[field]) entries.push([field, derived[field], "doctor output"]);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
if (verdict && verdict.run_id) entries.push(["qa run_id", verdict.run_id, "QA verdict"]);
|
|
1202
|
+
if (!entries.length) return;
|
|
1203
|
+
lines.push("RUN IDENTITY");
|
|
1204
|
+
for (const [field, value, source] of entries) lines.push(` ${field} = ${value} [${source}]`);
|
|
1205
|
+
lines.push("");
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** A blocker field is usable when it is a non-empty, non-blank string. */
|
|
1209
|
+
function usableText(value) {
|
|
1210
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/** Collapse embedded CR/LF so one blocker stays one rendered line. */
|
|
1214
|
+
function oneLine(value) {
|
|
1215
|
+
return value
|
|
1216
|
+
.split(/\r\n|\r|\n/)
|
|
1217
|
+
.filter((part) => part.trim())
|
|
1218
|
+
.join(" ");
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Render one stage blocker as one rendered entry.
|
|
1223
|
+
*
|
|
1224
|
+
* Campaigns OS assembly reports emit blockers as plain strings or as objects
|
|
1225
|
+
* carrying code/message/stage/page_id/detail. Known-schema text renders in full
|
|
1226
|
+
* with embedded CR/LF collapsed, so an object blocker is always one line
|
|
1227
|
+
* (BLOCKER_RENDER_CAP is the volume bound); only unknown shapes fall back to a
|
|
1228
|
+
* bounded JSON rendering, and that fallback never dumps an arbitrarily large
|
|
1229
|
+
* object into normal output.
|
|
1230
|
+
*
|
|
1231
|
+
* A plain string blocker is returned verbatim per the rendering contract, so a
|
|
1232
|
+
* string that already carries newlines still spans several lines.
|
|
1233
|
+
*/
|
|
1234
|
+
export function renderBlocker(blocker) {
|
|
1235
|
+
if (typeof blocker === "string") return blocker;
|
|
1236
|
+
if (isPlainObject(blocker)) {
|
|
1237
|
+
const text = usableText(blocker.message) ?? usableText(blocker.detail);
|
|
1238
|
+
if (text !== null) {
|
|
1239
|
+
const parts = [];
|
|
1240
|
+
const code = usableText(blocker.code);
|
|
1241
|
+
if (code !== null) parts.push(`[${oneLine(code)}]`);
|
|
1242
|
+
parts.push(oneLine(text));
|
|
1243
|
+
const identifiers = [];
|
|
1244
|
+
for (const field of BLOCKER_IDENTIFIER_FIELDS) {
|
|
1245
|
+
const value = usableText(blocker[field]);
|
|
1246
|
+
if (value !== null) identifiers.push(`${field}=${oneLine(value)}`);
|
|
1247
|
+
}
|
|
1248
|
+
if (identifiers.length) parts.push(`(${identifiers.join(", ")})`);
|
|
1249
|
+
return parts.join(" ");
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
let rendered;
|
|
1253
|
+
try {
|
|
1254
|
+
rendered = JSON.stringify(blocker);
|
|
1255
|
+
} catch {
|
|
1256
|
+
rendered = undefined;
|
|
1257
|
+
}
|
|
1258
|
+
if (rendered === undefined) rendered = String(blocker);
|
|
1259
|
+
if (rendered.length > NON_STRING_BLOCKER_RENDER_CAP) {
|
|
1260
|
+
rendered = `${rendered.slice(0, NON_STRING_BLOCKER_RENDER_CAP)}... (truncated)`;
|
|
1261
|
+
}
|
|
1262
|
+
return `(non-string entry, shown as written) ${rendered}`;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function renderStages(views, lines) {
|
|
1266
|
+
const report = artifactData(views, "report");
|
|
1267
|
+
if (report === null) return;
|
|
1268
|
+
lines.push(`STAGES [assembly report; report status: ${recorded(report.status)}]`);
|
|
1269
|
+
const stages = report.stages;
|
|
1270
|
+
if (isPlainObject(stages)) {
|
|
1271
|
+
for (const [name, stage] of Object.entries(stages)) {
|
|
1272
|
+
if (!isPlainObject(stage)) continue;
|
|
1273
|
+
const status = stage.status ?? "(no status recorded)";
|
|
1274
|
+
const counters = [];
|
|
1275
|
+
const blockers = stage.blockers;
|
|
1276
|
+
const warnings = stage.warnings;
|
|
1277
|
+
if (Array.isArray(blockers) && blockers.length) counters.push(`${blockers.length} blocker(s)`);
|
|
1278
|
+
if (Array.isArray(warnings) && warnings.length) counters.push(`${warnings.length} warning(s)`);
|
|
1279
|
+
const suffix = counters.length ? ` (${counters.join(", ")})` : "";
|
|
1280
|
+
lines.push(` ${name.padEnd(14)} ${status}${suffix}`);
|
|
1281
|
+
if (Array.isArray(blockers) && blockers.length) {
|
|
1282
|
+
for (const blocker of blockers.slice(0, BLOCKER_RENDER_CAP)) {
|
|
1283
|
+
lines.push(` blocker: ${renderBlocker(blocker)}`);
|
|
1284
|
+
}
|
|
1285
|
+
const hidden = blockers.length - BLOCKER_RENDER_CAP;
|
|
1286
|
+
if (hidden > 0) lines.push(` ... and ${hidden} more blocker(s) recorded in the assembly report`);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
lines.push("");
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function renderContext(views, lines) {
|
|
1294
|
+
const context = artifactData(views, "context");
|
|
1295
|
+
if (context === null) return;
|
|
1296
|
+
lines.push(
|
|
1297
|
+
`BUILD CONTEXT [build context; source adapter: ${recorded(context.source_adapter)}, ` +
|
|
1298
|
+
`status: ${recorded(context.status)}]`,
|
|
1299
|
+
);
|
|
1300
|
+
const prompts = context.prompts_required;
|
|
1301
|
+
if (Array.isArray(prompts) && prompts.length) {
|
|
1302
|
+
lines.push(` prompts recorded as required before first-shot assembly: ${prompts.length}`);
|
|
1303
|
+
}
|
|
1304
|
+
const brief = context.build_brief;
|
|
1305
|
+
if (isPlainObject(brief) && brief.status) {
|
|
1306
|
+
lines.push(
|
|
1307
|
+
` build brief status: ${brief.status} (${brief.question_count ?? 0} question(s), ` +
|
|
1308
|
+
`${brief.gate_count ?? 0} gate(s))`,
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
lines.push("");
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function doctorWarningGroups(doctor) {
|
|
1315
|
+
const groups = { "contract-static": [], "repo-observed": [] };
|
|
1316
|
+
for (const warning of doctor.warnings ?? []) {
|
|
1317
|
+
if (!isPlainObject(warning)) continue;
|
|
1318
|
+
groups[classifyDoctorWarning(warning.code)].push(warning);
|
|
1319
|
+
}
|
|
1320
|
+
return groups;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Summarize the doctor output: status, counts, and warning grouping.
|
|
1325
|
+
*
|
|
1326
|
+
* `present` is false when no doctor output loaded, and the counts are then zero
|
|
1327
|
+
* — the readback reports what it can see, and an absent doctor output is an
|
|
1328
|
+
* absence, not an observation of zero errors. Warnings are carried through as
|
|
1329
|
+
* doctor recorded them; only the two-way grouping is added.
|
|
1330
|
+
*/
|
|
1331
|
+
export function computeDoctorSummary(views) {
|
|
1332
|
+
const doctor = artifactData(views, "doctor");
|
|
1333
|
+
if (doctor === null) {
|
|
1334
|
+
return {
|
|
1335
|
+
present: false,
|
|
1336
|
+
status: null,
|
|
1337
|
+
error_count: 0,
|
|
1338
|
+
warning_count: 0,
|
|
1339
|
+
warning_groups: { "contract-static": [], "repo-observed": [] },
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
const groups = doctorWarningGroups(doctor);
|
|
1343
|
+
const errors = doctor.errors;
|
|
1344
|
+
return {
|
|
1345
|
+
present: true,
|
|
1346
|
+
status: doctor.status ?? null,
|
|
1347
|
+
error_count: Array.isArray(errors) ? errors.length : 0,
|
|
1348
|
+
warning_count: groups["contract-static"].length + groups["repo-observed"].length,
|
|
1349
|
+
warning_groups: groups,
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function renderDoctor(views, lines) {
|
|
1354
|
+
const doctor = artifactData(views, "doctor");
|
|
1355
|
+
if (doctor === null) return;
|
|
1356
|
+
lines.push(`DOCTOR [doctor output; status: ${doctor.status}]`);
|
|
1357
|
+
const errors = doctor.errors;
|
|
1358
|
+
if (Array.isArray(errors) && errors.length) {
|
|
1359
|
+
lines.push(` errors (${errors.length}):`);
|
|
1360
|
+
for (const error of errors) {
|
|
1361
|
+
if (isPlainObject(error)) {
|
|
1362
|
+
lines.push(` ${error.code ?? "(no code)"} — ${error.message ?? "(no message)"}`);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
const groups = doctorWarningGroups(doctor);
|
|
1367
|
+
const total = groups["contract-static"].length + groups["repo-observed"].length;
|
|
1368
|
+
lines.push(
|
|
1369
|
+
` warnings (${total}) — the contract-static / repo-observed labels are the ` +
|
|
1370
|
+
"readback's own projection layer, not doctor's",
|
|
1371
|
+
);
|
|
1372
|
+
for (const [label, note] of [
|
|
1373
|
+
["contract-static", CONTRACT_STATIC_LABEL_NOTE],
|
|
1374
|
+
["repo-observed", REPO_OBSERVED_LABEL_NOTE],
|
|
1375
|
+
]) {
|
|
1376
|
+
const group = groups[label];
|
|
1377
|
+
if (!group.length) continue;
|
|
1378
|
+
lines.push(` ${label} (${group.length}) — ${note}`);
|
|
1379
|
+
for (const warning of group) {
|
|
1380
|
+
lines.push(` ${warning.code ?? "(no code)"} — ${warning.message ?? "(no message)"}`);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
const nextState = doctor.next;
|
|
1384
|
+
if (isPlainObject(nextState)) {
|
|
1385
|
+
const blocked = nextState.blocked_stages;
|
|
1386
|
+
const blockedText = Array.isArray(blocked) && blocked.length ? `; blocked stages: ${blocked.join(", ")}` : "";
|
|
1387
|
+
// The status parenthetical is dropped rather than filled with a placeholder
|
|
1388
|
+
// when doctor recorded no status: "(not recorded)" inside parentheses reads
|
|
1389
|
+
// as a rendering fault, and the absence is already visible without it.
|
|
1390
|
+
const statusText = nextState.status === undefined || nextState.status === null ? "" : ` (${nextState.status})`;
|
|
1391
|
+
lines.push(` doctor's recorded next stage: ${recorded(nextState.stage)}${statusText}${blockedText}`);
|
|
1392
|
+
}
|
|
1393
|
+
lines.push("");
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/** Show, without adjudicating, where two artifacts record different states. */
|
|
1397
|
+
function renderDivergences(views, lines) {
|
|
1398
|
+
const divergences = computeDivergences(views);
|
|
1399
|
+
if (!divergences.length) return;
|
|
1400
|
+
lines.push(" cross-artifact divergence (readback's own layer):");
|
|
1401
|
+
for (const divergence of divergences) {
|
|
1402
|
+
lines.push(
|
|
1403
|
+
` the assembly report records stage '${divergence.stage}' as completed, while the ` +
|
|
1404
|
+
`QA verdict fails ${divergence.assertion_ids.join(", ")}; both records are shown as ` +
|
|
1405
|
+
"written — the readback does not adjudicate between artifacts",
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function renderVerdict(views, lines) {
|
|
1411
|
+
const verdict = artifactData(views, "qa_verdict");
|
|
1412
|
+
if (verdict === null) return;
|
|
1413
|
+
lines.push(
|
|
1414
|
+
`QA VERDICT [QA verdict; disposition: ${verdict.disposition} — Campaigns OS is the verdict authority]`,
|
|
1415
|
+
);
|
|
1416
|
+
const { fail: failed, pass: passed, skipped, other } = partitionAssertions(views);
|
|
1417
|
+
let countLine = ` assertions: ${failed.length} fail, ${passed.length} pass, ${skipped.length} skipped`;
|
|
1418
|
+
if (other.length) countLine += `, ${other.length} unrecognized status`;
|
|
1419
|
+
lines.push(countLine);
|
|
1420
|
+
for (const assertion of failed) {
|
|
1421
|
+
const severity = assertion.severity;
|
|
1422
|
+
const severityText = severity ? `, severity ${severity}` : "";
|
|
1423
|
+
lines.push(
|
|
1424
|
+
` fail ${recorded(assertion.id, "(no id)")} ` +
|
|
1425
|
+
`(family ${recorded(assertion.family, "(no family)")}${severityText})`,
|
|
1426
|
+
);
|
|
1427
|
+
const actual = assertion.actual;
|
|
1428
|
+
if (actual) lines.push(` recorded by Campaigns OS: ${actual}`);
|
|
1429
|
+
const evidence = assertion.evidence;
|
|
1430
|
+
const problems = isPlainObject(evidence) ? evidence.problems : null;
|
|
1431
|
+
if (Array.isArray(problems) && problems.length) {
|
|
1432
|
+
lines.push(` recorded problems (${problems.length}):`);
|
|
1433
|
+
for (const problem of problems) lines.push(` - ${problem}`);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
for (const assertion of passed) {
|
|
1437
|
+
const family = assertion.family || assertion.id || "(no family)";
|
|
1438
|
+
lines.push(` pass ${recorded(assertion.id, "(no id)")} (family ${family})`);
|
|
1439
|
+
}
|
|
1440
|
+
for (const assertion of other) {
|
|
1441
|
+
lines.push(
|
|
1442
|
+
` unrecognized status ${JSON.stringify(assertion.status ?? null)} ${recorded(assertion.id, "(no id)")} ` +
|
|
1443
|
+
`(family ${assertion.family || "(no family)"}) — shown as written; this readback ` +
|
|
1444
|
+
"projects fail, pass, and skipped statuses",
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
if (skipped.length) {
|
|
1449
|
+
lines.push(
|
|
1450
|
+
" skip provenance — the cascade grouping below is the readback's own " +
|
|
1451
|
+
"projection layer, derived from each skipped assertion's blocked_by field:",
|
|
1452
|
+
);
|
|
1453
|
+
for (const cascade of computeSkipCascades(views)) {
|
|
1454
|
+
const families = cascade.families;
|
|
1455
|
+
lines.push(
|
|
1456
|
+
` ${cascade.blocked_by} -> ${families.length} skipped ` +
|
|
1457
|
+
`famil${families.length === 1 ? "y" : "ies"}:`,
|
|
1458
|
+
);
|
|
1459
|
+
for (const family of families) lines.push(` - ${family}`);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
renderDivergences(views, lines);
|
|
1463
|
+
lines.push("");
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function renderFindings(views, lines) {
|
|
1467
|
+
const findingsExport = artifactData(views, "findings");
|
|
1468
|
+
if (findingsExport === null) return;
|
|
1469
|
+
const findings = (findingsExport.findings ?? []).filter((finding) => isPlainObject(finding));
|
|
1470
|
+
lines.push(`FINDINGS [findings export; ${findings.length} finding(s)]`);
|
|
1471
|
+
for (const finding of findings) {
|
|
1472
|
+
lines.push(
|
|
1473
|
+
` ${finding.stage ?? "(no stage)"} / ${finding.kind ?? "(no kind)"} — ` +
|
|
1474
|
+
`${finding.summary ?? "(no summary)"}`,
|
|
1475
|
+
);
|
|
1476
|
+
}
|
|
1477
|
+
lines.push("");
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* Render the one-view projection; pure function of its arguments.
|
|
1482
|
+
*
|
|
1483
|
+
* `staleness` is the optional result of assessStaleness; when omitted the
|
|
1484
|
+
* projection carries no staleness section, and the CLI always supplies one.
|
|
1485
|
+
* `packetSelection` is the optional selectPacketPath record; when omitted the
|
|
1486
|
+
* artifact table says nothing about how the packet was chosen. The
|
|
1487
|
+
* one-argument form keeps working: the artifact table shows each loaded
|
|
1488
|
+
* artifact's generated_at regardless of whether an assessment was supplied.
|
|
1489
|
+
*/
|
|
1490
|
+
export function projectReadback(views, staleness = null, packetSelection = null) {
|
|
1491
|
+
const lines = [
|
|
1492
|
+
"CAMPAIGNS OS RUN-ARTIFACT READBACK",
|
|
1493
|
+
"A read-only projection of this run's emitted artifacts. Campaigns OS",
|
|
1494
|
+
"remains the lifecycle and verdict authority; content marked as the",
|
|
1495
|
+
"readback's own projection layer is interpretation added by this view,",
|
|
1496
|
+
"not by Campaigns OS. This readback proposes no remediation.",
|
|
1497
|
+
"",
|
|
1498
|
+
];
|
|
1499
|
+
renderStaleness(staleness, lines);
|
|
1500
|
+
renderArtifactTable(views, lines, packetSelection);
|
|
1501
|
+
renderIdentity(views, lines);
|
|
1502
|
+
renderStages(views, lines);
|
|
1503
|
+
renderContext(views, lines);
|
|
1504
|
+
renderDoctor(views, lines);
|
|
1505
|
+
renderVerdict(views, lines);
|
|
1506
|
+
renderFindings(views, lines);
|
|
1507
|
+
if (Object.values(views).every((view) => view.state === "absent")) {
|
|
1508
|
+
lines.push(
|
|
1509
|
+
"No run artifacts were found at the projected paths. Either no run has " +
|
|
1510
|
+
"emitted artifacts here yet, or this is not a target campaign repository root.",
|
|
1511
|
+
);
|
|
1512
|
+
lines.push("");
|
|
1513
|
+
}
|
|
1514
|
+
return `${lines.join("\n").replace(/\n+$/, "")}\n`;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* Render one assessStaleness result as JSON-safe values.
|
|
1519
|
+
*
|
|
1520
|
+
* Instants become ISO-8601 UTC strings through the same formatter the text view
|
|
1521
|
+
* uses, so both modes name the same instants the same way.
|
|
1522
|
+
*/
|
|
1523
|
+
export function serializeStaleness(staleness) {
|
|
1524
|
+
if (!staleness) return null;
|
|
1525
|
+
const headTime = staleness.head_time;
|
|
1526
|
+
const artifacts = {};
|
|
1527
|
+
for (const [key, entry] of Object.entries(staleness.artifacts)) {
|
|
1528
|
+
artifacts[key] = { generated_at: formatUtc(entry.generated_at), stale: entry.stale };
|
|
1529
|
+
}
|
|
1530
|
+
const artifactTimes = {};
|
|
1531
|
+
for (const [key, value] of Object.entries(staleness.artifact_times)) artifactTimes[key] = formatUtc(value);
|
|
1532
|
+
return {
|
|
1533
|
+
computable: staleness.computable,
|
|
1534
|
+
stale: staleness.stale,
|
|
1535
|
+
stale_keys: [...staleness.stale_keys],
|
|
1536
|
+
unparseable_keys: [...staleness.unparseable_keys],
|
|
1537
|
+
artifacts,
|
|
1538
|
+
newest_key: staleness.newest_key,
|
|
1539
|
+
head_time: headTime === null ? null : formatUtc(headTime),
|
|
1540
|
+
head_detail: staleness.head_detail,
|
|
1541
|
+
artifact_times: artifactTimes,
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* Whether this projection shows nothing the readback can call wrong.
|
|
1547
|
+
*
|
|
1548
|
+
* True only when all five conditions hold: every artifact the readback found is
|
|
1549
|
+
* loaded and recognized (absent artifacts are not counted against it — a run
|
|
1550
|
+
* that emitted no findings export is not thereby unclean, while an unreadable
|
|
1551
|
+
* or unrecognized one always is); the staleness comparison is computable and NO
|
|
1552
|
+
* loaded artifact is stale; NO loaded artifact recorded a `generated_at` this
|
|
1553
|
+
* readback could not parse; no cross-artifact divergence was found; and the
|
|
1554
|
+
* doctor output records zero errors.
|
|
1555
|
+
*
|
|
1556
|
+
* The fourth of those is the unknown-age rule: an artifact whose recorded age
|
|
1557
|
+
* did not parse was never shown to be current, and `clean` states that the
|
|
1558
|
+
* readback CAN show every artifact is at least as new as the checkout. Without
|
|
1559
|
+
* it a fresh sibling carried the aggregate and an artifact of unestablished age
|
|
1560
|
+
* shipped inside a `clean: true` payload. An artifact that recorded no
|
|
1561
|
+
* `generated_at` at all is a different case and does not make the projection
|
|
1562
|
+
* unclean on its own — there is no claim about its age to fail to check — though
|
|
1563
|
+
* with no other artifact carrying one the comparison is not computable and
|
|
1564
|
+
* condition 2 fails anyway.
|
|
1565
|
+
*
|
|
1566
|
+
* This is a readback-integrity flag, not a verdict. Campaigns OS remains the
|
|
1567
|
+
* verdict authority: a QA verdict of `blocked` whose artifacts all read cleanly
|
|
1568
|
+
* is still `clean: true` here, because the readback saw exactly what Campaigns
|
|
1569
|
+
* OS recorded. docs/readback.md states the rule and its limits for callers
|
|
1570
|
+
* gating on it.
|
|
1571
|
+
*/
|
|
1572
|
+
export function computeClean(views, staleness) {
|
|
1573
|
+
if (Object.values(views).some((view) => !CLEAN_ARTIFACT_STATES.has(view.state))) return false;
|
|
1574
|
+
if (!staleness || !staleness.computable || staleness.stale) return false;
|
|
1575
|
+
if (staleness.unparseable_keys.length) return false;
|
|
1576
|
+
if (computeDivergences(views).length) return false;
|
|
1577
|
+
return computeDoctorSummary(views).error_count === 0;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* The `detail` an artifact row carries.
|
|
1582
|
+
*
|
|
1583
|
+
* A non-loaded state carries the readback's own explanation for it, as it
|
|
1584
|
+
* always has. A LOADED artifact whose recorded `generated_at` did not parse
|
|
1585
|
+
* carries the shape of that value: its row is the one place a consumer reading
|
|
1586
|
+
* artifacts alone would otherwise see nothing at all about an age the readback
|
|
1587
|
+
* failed to establish (`staleness.unparseable_keys` names it too, and `clean`
|
|
1588
|
+
* is false either way). Needs the assessment, so a payload built without one
|
|
1589
|
+
* leaves the row as the load left it.
|
|
1590
|
+
*/
|
|
1591
|
+
function artifactRowDetail(view, staleness) {
|
|
1592
|
+
const shape = view.state === "loaded" ? staleness?.unparseable_details?.[view.key] : null;
|
|
1593
|
+
if (!shape) return view.detail;
|
|
1594
|
+
return `generated_at is ${shape}, so this artifact's age could not be compared against the checkout`;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/**
|
|
1598
|
+
* Build the campaigns-os-readback/v2 object; pure function.
|
|
1599
|
+
*
|
|
1600
|
+
* Serializes what the text projection computes and interprets nothing further.
|
|
1601
|
+
* Artifact rows carry the state the readback assigned and never the artifact's
|
|
1602
|
+
* own `data` payload: a caller who wants an artifact's contents should read
|
|
1603
|
+
* that artifact.
|
|
1604
|
+
*
|
|
1605
|
+
* `packet_selection` and `staleness` are `null` when a programmatic caller
|
|
1606
|
+
* builds a payload without them; the CLI always supplies both.
|
|
1607
|
+
*/
|
|
1608
|
+
export function buildJsonPayload(views, staleness = null, packetSelection = null) {
|
|
1609
|
+
return {
|
|
1610
|
+
schema_version: JSON_SCHEMA_VERSION,
|
|
1611
|
+
artifacts: Object.values(views).map((view) => ({
|
|
1612
|
+
key: view.key,
|
|
1613
|
+
path: view.path,
|
|
1614
|
+
state: view.state,
|
|
1615
|
+
detail: artifactRowDetail(view, staleness),
|
|
1616
|
+
})),
|
|
1617
|
+
packet_selection: packetSelection,
|
|
1618
|
+
staleness: serializeStaleness(staleness),
|
|
1619
|
+
doctor: computeDoctorSummary(views),
|
|
1620
|
+
skip_cascades: computeSkipCascades(views),
|
|
1621
|
+
divergences: computeDivergences(views),
|
|
1622
|
+
clean: computeClean(views, staleness),
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function isFilePath(path) {
|
|
1627
|
+
const info = statOrNull(path);
|
|
1628
|
+
return Boolean(info && info.isFile());
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/** Root-level Build Packet files, default first, then suffixed by name. */
|
|
1632
|
+
function packetCandidatePaths(rootPath) {
|
|
1633
|
+
const candidates = [];
|
|
1634
|
+
const defaultName = DEFAULT_RELATIVE_PATHS.packet;
|
|
1635
|
+
if (isFilePath(join(rootPath, defaultName))) candidates.push(defaultName);
|
|
1636
|
+
let names = [];
|
|
1637
|
+
try {
|
|
1638
|
+
names = readdirSync(rootPath);
|
|
1639
|
+
} catch {
|
|
1640
|
+
// An unreadable root has no candidates; resolveProjection has already
|
|
1641
|
+
// refused a root that is not a directory at all.
|
|
1642
|
+
names = [];
|
|
1643
|
+
}
|
|
1644
|
+
const suffixed = names.filter((name) => PACKET_CANDIDATE_PATTERN.test(name)).sort();
|
|
1645
|
+
for (const name of suffixed) if (isFilePath(join(rootPath, name))) candidates.push(name);
|
|
1646
|
+
return candidates;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* Choose the Build Packet to project, and record how it was chosen.
|
|
1651
|
+
*
|
|
1652
|
+
* Returns `{ path, selection, view }`: the packet to project, how it was
|
|
1653
|
+
* chosen, and the already-loaded view of it when discovery read it, so the
|
|
1654
|
+
* caller does not read the same packet a second time (`view` is null for an
|
|
1655
|
+
* explicit `--packet` and for a target with no valid candidate, which discovery
|
|
1656
|
+
* never read).
|
|
1657
|
+
*
|
|
1658
|
+
* `selection` is the record the text and JSON views both publish: `mode` is
|
|
1659
|
+
* `explicit` when the caller passed `--packet`, `default` when discovery landed
|
|
1660
|
+
* on the fixed default name (including the no-candidate case, where the default
|
|
1661
|
+
* path is still what the readback reports as absent), and `discovered` when
|
|
1662
|
+
* freshness picked a packet out of several. `signal` names what decided it —
|
|
1663
|
+
* `explicit`, `sole_candidate`, `generated_at`, or `none` when nothing was
|
|
1664
|
+
* there to choose between.
|
|
1665
|
+
*
|
|
1666
|
+
* Freshness is the packet's own recorded `generated_at`, never the file's
|
|
1667
|
+
* modification time: mtimes are rewritten by clones, checkouts, and copies
|
|
1668
|
+
* without any run having recorded anything, while `generated_at` is what the
|
|
1669
|
+
* emitting run wrote down. The consequence is that selection stays a pure
|
|
1670
|
+
* function of file contents, so two callers reading the same packets always
|
|
1671
|
+
* select the same one.
|
|
1672
|
+
*
|
|
1673
|
+
* Throws ReadbackUsageError when several valid candidates exist and
|
|
1674
|
+
* `generated_at` does not single one out — a tie, or any candidate missing a
|
|
1675
|
+
* parseable value. A stale packet chosen silently is the failure this refusal
|
|
1676
|
+
* exists to prevent, so the caller is told to pass `--packet`.
|
|
1677
|
+
*/
|
|
1678
|
+
export function selectPacketPath(rootPath, override) {
|
|
1679
|
+
if (override) {
|
|
1680
|
+
return {
|
|
1681
|
+
path: String(override),
|
|
1682
|
+
selection: {
|
|
1683
|
+
mode: "explicit",
|
|
1684
|
+
signal: "explicit",
|
|
1685
|
+
candidates_considered: [],
|
|
1686
|
+
rejected: [],
|
|
1687
|
+
selected: null,
|
|
1688
|
+
},
|
|
1689
|
+
view: null,
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
const considered = [];
|
|
1694
|
+
const rejected = [];
|
|
1695
|
+
const views = new Map();
|
|
1696
|
+
const times = new Map();
|
|
1697
|
+
for (const name of packetCandidatePaths(rootPath)) {
|
|
1698
|
+
const view = loadArtifact("packet", join(rootPath, name));
|
|
1699
|
+
if (view.state !== "loaded") {
|
|
1700
|
+
rejected.push({ path: name, reason: view.detail });
|
|
1701
|
+
continue;
|
|
1702
|
+
}
|
|
1703
|
+
considered.push(name);
|
|
1704
|
+
views.set(name, view);
|
|
1705
|
+
times.set(name, parseIsoInstant((view.data || {}).generated_at));
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
if (!considered.length) {
|
|
1709
|
+
if (isFilePath(join(rootPath, SIDECAR_PACKET_RELATIVE))) {
|
|
1710
|
+
rejected.push({
|
|
1711
|
+
path: SIDECAR_PACKET_RELATIVE,
|
|
1712
|
+
reason:
|
|
1713
|
+
"file present at sidecar location; contracted packet home is the " +
|
|
1714
|
+
"repository root. Pass --packet to project it.",
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
return {
|
|
1718
|
+
path: join(rootPath, DEFAULT_RELATIVE_PATHS.packet),
|
|
1719
|
+
selection: { mode: "default", signal: "none", candidates_considered: [], rejected, selected: null },
|
|
1720
|
+
view: null,
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
const chose = (name, signal) => ({
|
|
1725
|
+
path: join(rootPath, name),
|
|
1726
|
+
selection: {
|
|
1727
|
+
mode: name === DEFAULT_RELATIVE_PATHS.packet ? "default" : "discovered",
|
|
1728
|
+
signal,
|
|
1729
|
+
candidates_considered: [...considered],
|
|
1730
|
+
rejected,
|
|
1731
|
+
selected: name,
|
|
1732
|
+
},
|
|
1733
|
+
view: views.get(name),
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
if (considered.length === 1) return chose(considered[0], "sole_candidate");
|
|
1737
|
+
|
|
1738
|
+
if (considered.some((name) => times.get(name) === null)) {
|
|
1739
|
+
const detail = considered
|
|
1740
|
+
.map((name) => {
|
|
1741
|
+
const time = times.get(name);
|
|
1742
|
+
return `${name} ${time === null ? "(no parseable generated_at)" : `(generated_at ${formatUtc(time.date)})`}`;
|
|
1743
|
+
})
|
|
1744
|
+
.join(", ");
|
|
1745
|
+
throw new ReadbackUsageError(
|
|
1746
|
+
"one or more Build Packets at the target root carry no parseable generated_at, " +
|
|
1747
|
+
`so freshness cannot single out a packet: ${detail}. ` +
|
|
1748
|
+
"Pass --packet to name the Build Packet to project.",
|
|
1749
|
+
);
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// Freshness is compared in microseconds: a tie here means the packets record
|
|
1753
|
+
// the same instant to the microsecond, not merely the same millisecond.
|
|
1754
|
+
let newest = times.get(considered[0]).micros;
|
|
1755
|
+
for (const name of considered) {
|
|
1756
|
+
if (times.get(name).micros > newest) newest = times.get(name).micros;
|
|
1757
|
+
}
|
|
1758
|
+
const freshest = considered.filter((name) => times.get(name).micros === newest);
|
|
1759
|
+
if (freshest.length > 1) {
|
|
1760
|
+
throw new ReadbackUsageError(
|
|
1761
|
+
`several Build Packets at the target root share the newest generated_at ` +
|
|
1762
|
+
`(${formatUtc(times.get(freshest[0]).date)}): ${freshest.join(", ")}. ` +
|
|
1763
|
+
"Pass --packet to name the Build Packet to project.",
|
|
1764
|
+
);
|
|
1765
|
+
}
|
|
1766
|
+
return chose(freshest[0], "generated_at");
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* Return `{ paths, packetSelection, packetView }` for one target root.
|
|
1771
|
+
*
|
|
1772
|
+
* `packetView` is the Build Packet view discovery already loaded, ready to hand
|
|
1773
|
+
* to loadArtifacts as `preloaded` so no artifact is read twice; it is null when
|
|
1774
|
+
* discovery read no packet.
|
|
1775
|
+
*/
|
|
1776
|
+
export function resolveProjection(root, overrides = {}) {
|
|
1777
|
+
const info = statOrNull(root);
|
|
1778
|
+
if (!info || !info.isDirectory()) {
|
|
1779
|
+
throw new ReadbackUsageError(`target repository root must name an existing directory: ${root}`);
|
|
1780
|
+
}
|
|
1781
|
+
const { path: packetPath, selection, view } = selectPacketPath(root, overrides.packet);
|
|
1782
|
+
const paths = { packet: packetPath };
|
|
1783
|
+
for (const [key, relative] of Object.entries(DEFAULT_RELATIVE_PATHS)) {
|
|
1784
|
+
if (key === "packet") continue;
|
|
1785
|
+
paths[key] = overrides[key] ? String(overrides[key]) : join(root, relative);
|
|
1786
|
+
}
|
|
1787
|
+
return { paths, packetSelection: selection, packetView: view };
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/** The artifact paths to project; discovery result discarded. */
|
|
1791
|
+
export function resolvePaths(root, overrides = {}) {
|
|
1792
|
+
return resolveProjection(root, overrides).paths;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/** Project one target root: load every artifact once, then assess freshness. */
|
|
1796
|
+
export function projectTarget(root, overrides = {}) {
|
|
1797
|
+
const { paths, packetSelection, packetView } = resolveProjection(root, overrides);
|
|
1798
|
+
const views = loadArtifacts(paths, { packet: packetView });
|
|
1799
|
+
return { views, staleness: assessStaleness(root, views), packetSelection };
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// The bundled synthetic sample `--example` projects. It is already on the
|
|
1803
|
+
// supported surface as the sidecar-bundle conformance fixture, so the readback
|
|
1804
|
+
// reuses it rather than shipping a second copy of the same artifact set.
|
|
1805
|
+
export const EXAMPLE_RELATIVE_ROOT = "contracts/fixtures/sidecar-bundle/production-shaped";
|
|
1806
|
+
export const EXAMPLE_ROOT = fileURLToPath(
|
|
1807
|
+
new URL(`../${EXAMPLE_RELATIVE_ROOT}/`, import.meta.url),
|
|
1808
|
+
);
|
|
1809
|
+
|
|
1810
|
+
// The sample is a packaged fixture directory, not a Git checkout, so there is
|
|
1811
|
+
// no HEAD movement to compare its artifacts against. Stating that as a fixed
|
|
1812
|
+
// detail keeps `--example` identical wherever the package is installed: were
|
|
1813
|
+
// the sample's freshness read from the filesystem, the answer would depend on
|
|
1814
|
+
// whether the installing repository happens to be a checkout.
|
|
1815
|
+
export const EXAMPLE_HEAD_DETAIL =
|
|
1816
|
+
"the bundled sample is a packaged fixture directory, not a Git checkout: " +
|
|
1817
|
+
"freshness is not computable for it by design";
|
|
1818
|
+
|
|
1819
|
+
/**
|
|
1820
|
+
* Project the bundled synthetic sample; reads only files inside the package.
|
|
1821
|
+
*
|
|
1822
|
+
* Artifact rows report the sample's paths relative to the package root rather
|
|
1823
|
+
* than where the package happens to be installed. An absolute path would make
|
|
1824
|
+
* the sample's own output different on every machine, and the point of a
|
|
1825
|
+
* bundled sample is that everyone reading the docs sees what they ran.
|
|
1826
|
+
*/
|
|
1827
|
+
export function projectExample() {
|
|
1828
|
+
const { paths, packetSelection, packetView } = resolveProjection(EXAMPLE_ROOT, {});
|
|
1829
|
+
const views = loadArtifacts(paths, { packet: packetView });
|
|
1830
|
+
for (const view of Object.values(views)) {
|
|
1831
|
+
const within = view.path.slice(EXAMPLE_ROOT.length).replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1832
|
+
view.path = `${EXAMPLE_RELATIVE_ROOT}/${within}`;
|
|
1833
|
+
}
|
|
1834
|
+
const staleness = assessStaleness(EXAMPLE_ROOT, views, {
|
|
1835
|
+
headMovement: { time: null, detail: EXAMPLE_HEAD_DETAIL },
|
|
1836
|
+
});
|
|
1837
|
+
return { views, staleness, packetSelection };
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
const OVERRIDE_FLAGS = {
|
|
1841
|
+
packet: "packet",
|
|
1842
|
+
doctor: "doctor",
|
|
1843
|
+
context: "context",
|
|
1844
|
+
report: "report",
|
|
1845
|
+
"qa-verdict": "qa_verdict",
|
|
1846
|
+
findings: "findings",
|
|
1847
|
+
};
|
|
1848
|
+
|
|
1849
|
+
// `--example <anything>` parses as a flag carrying a value, so the refusal has
|
|
1850
|
+
// to name the shape the caller probably meant: a target written after
|
|
1851
|
+
// `--example` is swallowed as its value and never reaches the positional list.
|
|
1852
|
+
const EXAMPLE_USAGE =
|
|
1853
|
+
"--example projects the bundled synthetic sample and takes no target or path override. " +
|
|
1854
|
+
"Use `campaigns-os readback --example [--json]`, or name a target without --example.";
|
|
1855
|
+
|
|
1856
|
+
function booleanFlag(args, flag, extra = "") {
|
|
1857
|
+
const value = args[flag];
|
|
1858
|
+
if (value === undefined) return false;
|
|
1859
|
+
if (value !== true) throw new ReadbackUsageError(`--${flag} is a boolean flag and takes no value.${extra}`);
|
|
1860
|
+
return true;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/**
|
|
1864
|
+
* Validate one `campaigns-os readback` invocation into a projection request.
|
|
1865
|
+
*
|
|
1866
|
+
* Throws ReadbackUsageError for anything that cannot form a projection; the
|
|
1867
|
+
* dispatcher turns that into the exit-2 usage path.
|
|
1868
|
+
*/
|
|
1869
|
+
export function readbackRequest(args) {
|
|
1870
|
+
// `--example` is settled before anything else is validated: where both
|
|
1871
|
+
// refusals apply, the one naming --example is the actionable one. In
|
|
1872
|
+
// `readback --example --json <target>` the target is swallowed as --json's
|
|
1873
|
+
// value by the same parser rule that swallows it after --example itself, and
|
|
1874
|
+
// "--json is a boolean flag and takes no value" sends the caller to fix the
|
|
1875
|
+
// wrong flag; the same goes for an override flag left without a value.
|
|
1876
|
+
const example = booleanFlag(args, "example", ` ${EXAMPLE_USAGE}`);
|
|
1877
|
+
const positionals = (args._ ?? []).slice(1);
|
|
1878
|
+
if (example) {
|
|
1879
|
+
const named = Object.keys(OVERRIDE_FLAGS).filter((flag) => args[flag] !== undefined);
|
|
1880
|
+
// A --json carrying a value is a target the parser ate, not a misused
|
|
1881
|
+
// boolean, so it is reported here with the value the caller wrote.
|
|
1882
|
+
const swallowed = args.json !== undefined && args.json !== true ? [`--json ${args.json}`] : [];
|
|
1883
|
+
if (positionals.length || named.length || swallowed.length) {
|
|
1884
|
+
throw new ReadbackUsageError(
|
|
1885
|
+
`${EXAMPLE_USAGE} Got ${[...positionals, ...named.map((flag) => `--${flag}`), ...swallowed].join(", ")}.`,
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
return { example: true, json: booleanFlag(args, "json"), target: null, overrides: {} };
|
|
1889
|
+
}
|
|
1890
|
+
const json = booleanFlag(args, "json");
|
|
1891
|
+
const overrides = {};
|
|
1892
|
+
for (const [flag, key] of Object.entries(OVERRIDE_FLAGS)) {
|
|
1893
|
+
const value = args[flag];
|
|
1894
|
+
if (value === undefined) continue;
|
|
1895
|
+
if (typeof value !== "string" || !value.trim()) throw new ReadbackUsageError(`Missing value for --${flag}.`);
|
|
1896
|
+
overrides[key] = value;
|
|
1897
|
+
}
|
|
1898
|
+
if (positionals.length > 1) {
|
|
1899
|
+
throw new ReadbackUsageError(
|
|
1900
|
+
`readback projects one target repository root; got ${positionals.length}: ${positionals.join(", ")}.`,
|
|
1901
|
+
);
|
|
1902
|
+
}
|
|
1903
|
+
if (!positionals.length) {
|
|
1904
|
+
throw new ReadbackUsageError(
|
|
1905
|
+
"Use: campaigns-os readback <target-repo-root> [--json] [--packet <path>] " +
|
|
1906
|
+
"[--doctor <path>] [--context <path>] [--report <path>] [--qa-verdict <path>] " +
|
|
1907
|
+
"[--findings <path>], or campaigns-os readback --example [--json].",
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
return { example: false, json, target: positionals[0], overrides };
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
/**
|
|
1914
|
+
* Run one readback invocation and return what to print.
|
|
1915
|
+
*
|
|
1916
|
+
* Returns `{ exitCode, text }`: exit 0 with the projection for any target the
|
|
1917
|
+
* readback could form a view of — an unreadable artifact is a state it reports,
|
|
1918
|
+
* not an error — and exit 2 with a one-line reason for a caller request that
|
|
1919
|
+
* cannot form a projection at all.
|
|
1920
|
+
*/
|
|
1921
|
+
export function runReadbackCommand(args) {
|
|
1922
|
+
let request;
|
|
1923
|
+
let projection;
|
|
1924
|
+
try {
|
|
1925
|
+
request = readbackRequest(args);
|
|
1926
|
+
projection = request.example ? projectExample() : projectTarget(request.target, request.overrides);
|
|
1927
|
+
} catch (error) {
|
|
1928
|
+
if (error instanceof ReadbackUsageError) return { exitCode: 2, text: `${error.message}\n` };
|
|
1929
|
+
throw error;
|
|
1930
|
+
}
|
|
1931
|
+
const { views, staleness, packetSelection } = projection;
|
|
1932
|
+
const text = request.json
|
|
1933
|
+
? `${JSON.stringify(buildJsonPayload(views, staleness, packetSelection), null, 2)}\n`
|
|
1934
|
+
: projectReadback(views, staleness, packetSelection);
|
|
1935
|
+
return { exitCode: 0, text };
|
|
1936
|
+
}
|