@sleepy_panda_srl/portulan 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cli/README.md +69 -2
- package/cli/compile.mjs +555 -37
- package/cli/doctor.mjs +5 -1
- package/cli/drills.mjs +1541 -0
- package/cli/fuzz-shell.mjs +880 -0
- package/cli/gate.mjs +3 -2
- package/cli/goldens.mjs +469 -0
- package/cli/init.mjs +4 -1
- package/cli/mutants.mjs +925 -0
- package/cli/pack-identity.mjs +9 -2
- package/cli/pack-version.mjs +7 -0
- package/cli/recipe-set.mjs +6 -0
- package/cli/release-eval.mjs +1068 -0
- package/cli/review-meter.mjs +741 -0
- package/cli/skill-goldens.mjs +590 -0
- package/cli/stop-gate.mjs +107 -4
- package/cli/telemetry.mjs +1047 -0
- package/cli/version-carriers.mjs +13 -0
- package/core/templates/gate-policy.md +27 -8
- package/evals/README.md +910 -0
- package/evals/releases/0.1.3.json +134 -0
- package/evals/releases/0.1.3.md +64 -0
- package/evals/releases/README.md +32 -0
- package/package.json +7 -2
- package/packs/rituals/checkpoints/README.md +8 -1
- package/packs/rituals/checkpoints/pack.json +1 -1
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
- package/spec/slots.md +26 -2
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The OTel emitter — milestone 8's *OTel opt-in config*, and the first thing in this repository that
|
|
3
|
+
// can send anything anywhere on purpose.
|
|
4
|
+
//
|
|
5
|
+
// The argument for the clause is in `../docs/milestones/m08.md`; what belongs here is what the
|
|
6
|
+
// opt-in IS, why it is not the standard environment surface, and — at more length than is
|
|
7
|
+
// comfortable — what a silent run means, because for an emitter whose correct default state is
|
|
8
|
+
// silence, *did nothing* and *is broken* look identical from outside.
|
|
9
|
+
//
|
|
10
|
+
// ## The reading was ruled, not derived
|
|
11
|
+
//
|
|
12
|
+
// *"OTel opt-in config"* reads at least three ways, and `../.portulan/gate-map.md`'s
|
|
13
|
+
// *"Session-open runs `clarify` against the milestone row itself"* exists because milestone 4 guessed
|
|
14
|
+
// at exactly this shape of ambiguity and cost a session-blocking question. Put to the maintainer on
|
|
15
|
+
// 2026-08-28 as three readings: **(i)** a validated config surface emitting nothing; **(ii)** that
|
|
16
|
+
// surface plus a real emitter — hand-written OTLP-over-HTTP JSON, no OpenTelemetry SDK — off by
|
|
17
|
+
// default and never callable from a verify recipe; **(iii)** an OTLP-shaped local file sink with no
|
|
18
|
+
// network path at all. **He ruled (ii).**
|
|
19
|
+
//
|
|
20
|
+
// Reading (i) is what this file would be if the emitter were deferred, and it fails
|
|
21
|
+
// `../.portulan/dod.md` condition 4 in a way the *names the milestone where it arrives* escape does
|
|
22
|
+
// not cover: under the row's `1 per clause` budget the clause would be spent with the emitter owned
|
|
23
|
+
// by no session — the *rule with no owner* shape the 2026-08-24 amendment exists to end, one
|
|
24
|
+
// altitude up from a rule with no checker.
|
|
25
|
+
//
|
|
26
|
+
// ## Why the opt-in is a committed file and NOT `OTEL_SDK_DISABLED`
|
|
27
|
+
//
|
|
28
|
+
// This is the one place this tool deliberately departs from the OpenTelemetry environment contract,
|
|
29
|
+
// and it is stated out loud rather than left for a reader to discover from the code.
|
|
30
|
+
//
|
|
31
|
+
// **`OTEL_SDK_DISABLED` defaults to `false`.** Honouring the standard env surface as the gate would
|
|
32
|
+
// mean an adopter who already exports `OTEL_EXPORTER_OTLP_ENDPOINT` in CI — for some other
|
|
33
|
+
// service — starts emitting from Portulan without having decided to. That is an opt-**out**, and the
|
|
34
|
+
// criterion's word is *opt-in*. It would also put a team decision in ambient per-machine state,
|
|
35
|
+
// which is the inverse of the cascade's `core < pack < workspace`: the workspace is where a team's
|
|
36
|
+
// policy lives.
|
|
37
|
+
//
|
|
38
|
+
// So: **the committed config file is the only gate**, and the environment supplies transport only —
|
|
39
|
+
// the OTLP endpoint and header variables, in both their general and their metrics-specific forms, so
|
|
40
|
+
// an adopter's existing collector configuration works unchanged. A secret never enters the committed
|
|
41
|
+
// file; that is `../.portulan/dod.md` condition 5 holding by construction rather than by a reviewer
|
|
42
|
+
// noticing.
|
|
43
|
+
//
|
|
44
|
+
// **What of that contract is implemented, stated at its real width rather than as *"exactly as the
|
|
45
|
+
// specification defines them"*, which is what this paragraph used to say and was false.** Read:
|
|
46
|
+
// `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` (used as given) falling back to `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
47
|
+
// (a base, with `/v1/metrics` appended), and `OTEL_EXPORTER_OTLP_METRICS_HEADERS` **replacing**
|
|
48
|
+
// `OTEL_EXPORTER_OTLP_HEADERS` rather than merging with it. **Not** read, and each would be a silent
|
|
49
|
+
// no-op for an adopter who set it: `OTEL_EXPORTER_OTLP_PROTOCOL` — this emitter speaks
|
|
50
|
+
// `http/json` and nothing else — `_TIMEOUT`, `_COMPRESSION`, `_CERTIFICATE` and the client-auth pair.
|
|
51
|
+
// Naming them is the point: a partial implementation of a standard contract is fine, and a partial
|
|
52
|
+
// implementation described as a complete one is the overclaim `../.portulan/principles.md` opens with.
|
|
53
|
+
//
|
|
54
|
+
// _An earlier draft of this clause put the config in the Workspace Definition as a `telemetry` slot
|
|
55
|
+
// at spec 2.9. The session-open checkpoint cut it: every slot in that train arrived through a **ruled
|
|
56
|
+
// proposal** (`../spec/README.md`'s 2.8 argument cites proposal `0025`; `governed_by` cites `0017`;
|
|
57
|
+
// `provenance` cites `0002`), and `../.portulan/gate-map.md` makes an idea that adds a surface a
|
|
58
|
+
// proposal rather than an implementation pull request. `../spec/slots.md`'s own `evals` deferral says
|
|
59
|
+
// a schema change plus a spec bump plus a migration **"is not a thing to do in passing"** and defers
|
|
60
|
+
// to *when milestone 8 closes* — and the slot would have shipped with zero filled instances, since
|
|
61
|
+
// this workspace declares telemetry off and `../examples/` stays at 2.4. The slot is filed to ride
|
|
62
|
+
// with `evals` at the close, which makes it one bump instead of two. **Milestone 8 closed 2026-09-09,
|
|
63
|
+
// so that deferral is now DUE and undrafted** — proposal `0034` commissions it and settles none of the
|
|
64
|
+
// shape; the close named it rather than drafting a schema inside itself._
|
|
65
|
+
//
|
|
66
|
+
// ## Consent — ruled 2026-08-28, and it is a reading of a tier rather than a new one
|
|
67
|
+
//
|
|
68
|
+
// `../.portulan/gates.json` makes *sending anything outward on the team's behalf* Gated, and
|
|
69
|
+
// `../core/operating/autonomy.md` says Gated is **per action**. Proposal `0014` reserves telemetry as
|
|
70
|
+
// *"a separate mechanism with separate consent"* — named there, never ruled, until now.
|
|
71
|
+
//
|
|
72
|
+
// **The maintainer's ruling: the opt-in config IS the standing consent.** A committed config with
|
|
73
|
+
// `enabled: true` is the team's decision, and each subsequent export rides on it without a fresh
|
|
74
|
+
// per-send approval — the way a compiled gate carries a tier without asking again. What stays his
|
|
75
|
+
// alone is **enabling it in this workspace**, which is why the committed config here says `false` and
|
|
76
|
+
// why no endpoint is committed anywhere in this tree.
|
|
77
|
+
//
|
|
78
|
+
// What makes that safe rather than merely decided is the payload, and the payload is closed by
|
|
79
|
+
// construction: it is assembled from an explicit allow-list of aggregate figures, and there is no
|
|
80
|
+
// path from a snapshot's free-text or path-shaped fields into a request body. See `PRODUCERS` below,
|
|
81
|
+
// where the allow-list is the code rather than a promise about it.
|
|
82
|
+
//
|
|
83
|
+
// ## What a silent run means — the trap this file is most likely to ship
|
|
84
|
+
//
|
|
85
|
+
// `./review-meter.mjs` shipped a broken entry guard and `--fetch` **printed nothing, exited 0, and
|
|
86
|
+
// wrote no snapshot** — the fourth time this repository has met that shape. For every other tool
|
|
87
|
+
// here that is a bad day. For an emitter it is worse: **the correct behaviour of an opted-out
|
|
88
|
+
// emitter is also to send nothing**, so a tool that never started and a tool working exactly as
|
|
89
|
+
// designed produce the same observable. There is no way to tell them apart from outside.
|
|
90
|
+
//
|
|
91
|
+
// Two consequences, both load-bearing:
|
|
92
|
+
//
|
|
93
|
+
// * The entry guard is the ONE form `./rule-carriers.mjs` designates, copied rather than
|
|
94
|
+
// re-derived, and the suite pins it under a path containing a space.
|
|
95
|
+
// * **Every refusal prints a sentence and returns non-zero.** Silence is never this tool's answer
|
|
96
|
+
// to anything. A caller may read exit 0 as *the payload went where it was asked to go* and
|
|
97
|
+
// nothing else.
|
|
98
|
+
//
|
|
99
|
+
// And the two non-export states are kept apart, because collapsing them is its own seam defect: a
|
|
100
|
+
// config file with a typo that its author meant to be `true` must not report *opted out*.
|
|
101
|
+
//
|
|
102
|
+
// 0 the thing asked for happened
|
|
103
|
+
// 1 a verdict: the golden has drifted, or `--export` was asked of a workspace that is opted out
|
|
104
|
+
// 2 could not run: the config or the snapshot is missing, unreadable, or malformed
|
|
105
|
+
//
|
|
106
|
+
// _`../.portulan/memory/verify-preconditions-fail-closed.md` is deliberately NOT cited for the
|
|
107
|
+
// opted-out arm. That record is scoped to verify recipes and its subject is an enumeration that came
|
|
108
|
+
// back empty; `--export` against a workspace legitimately off is not a failed precondition — the tool
|
|
109
|
+
// looked, and the answer was no. The checkpoint caught this file citing it and it is a
|
|
110
|
+
// misapplication, so it is named here as one._
|
|
111
|
+
//
|
|
112
|
+
// ## Deliberately not a ninth `portulan` subcommand
|
|
113
|
+
//
|
|
114
|
+
// `../docs/vision.md` names eight and is human-owned; `../.portulan/identity.md` says a ninth is the
|
|
115
|
+
// maintainer's call. The precedent is `./eval-bundle.mjs`, `./drills.mjs`, `./review-meter.mjs` and
|
|
116
|
+
// `./skill-goldens.mjs`, each of which carries this same sentence. Run it as
|
|
117
|
+
// `node cli/telemetry.mjs …`.
|
|
118
|
+
|
|
119
|
+
import fs from "node:fs";
|
|
120
|
+
import path from "node:path";
|
|
121
|
+
import { spawnSync } from "node:child_process";
|
|
122
|
+
import { pathToFileURL } from "node:url";
|
|
123
|
+
|
|
124
|
+
import { meter, validateSnapshot } from "./review-meter.mjs";
|
|
125
|
+
import { VERSION } from "./manifest.mjs";
|
|
126
|
+
import { recipeSet, resolverFor } from "./recipe-set.mjs";
|
|
127
|
+
import { isInside } from "./inside.mjs";
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------------------------
|
|
130
|
+
// The offline audit — no verify recipe may reach the network, and now something checks
|
|
131
|
+
// ---------------------------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The network-capable modes in `cli/`, and the flag that turns each one on.
|
|
135
|
+
*
|
|
136
|
+
* **The subject is the MODE, not the module.** `../.portulan/verify/review-loop.sh` legitimately runs
|
|
137
|
+
* `./review-meter.mjs` — in its snapshot-reading mode, which touches nothing. What a recipe may never
|
|
138
|
+
* do is reach the mode that opens a socket. A rail that banned the module would forbid an existing
|
|
139
|
+
* green recipe; a rail that bans the flag forbids the thing actually prohibited.
|
|
140
|
+
*
|
|
141
|
+
* **It rails the CLASS rather than this session's own module**, which is the whole reason it is a
|
|
142
|
+
* table. Adding `./telemetry.mjs` and railing only it would have left `--fetch` unrailed — one rule
|
|
143
|
+
* enforced at the newer of its two sites, which is the defect
|
|
144
|
+
* `../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md` names and the one this
|
|
145
|
+
* repository repeats most. A new network mode adds a row here or it is uncovered, and nothing can
|
|
146
|
+
* audit that a row was added — the arrears are named rather than pretended away, as
|
|
147
|
+
* `../.portulan/rule-carriers.json` does for its own registry.
|
|
148
|
+
*/
|
|
149
|
+
export const NETWORK_MODES = Object.freeze([
|
|
150
|
+
{ module: "cli/review-meter.mjs", flag: "--fetch", what: "fetches review data from GitHub" },
|
|
151
|
+
{ module: "cli/telemetry.mjs", flag: "--export", what: "posts an OTLP payload to a collector" },
|
|
152
|
+
// **Added at round 10, and its absence is the defect this table's own docblock warns about,
|
|
153
|
+
// committed at the table's birth.** `./feedback.mjs` files a GitHub issue through
|
|
154
|
+
// `gh issue create`, gated on `--approve` exactly as this module's export is gated on a committed
|
|
155
|
+
// config. It was network-capable before either of the rows above existed, and enumerating two of
|
|
156
|
+
// three while claiming to rail *the class* is the census-over-its-own-set shape milestone 8's own
|
|
157
|
+
// session 4 named: a set drawn by its author, reported as complete.
|
|
158
|
+
{ module: "cli/feedback.mjs", flag: "--approve", what: "files a GitHub issue with `gh issue create`" },
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Strip shell comments so a recipe may **explain** a network mode without being accused of running
|
|
163
|
+
* one. `../.portulan/verify/review-loop.sh` says *"Refreshing is `node cli/review-meter.mjs --fetch`,
|
|
164
|
+
* run by a person"* in a comment, and a matcher that could not tell prose from a command would red on
|
|
165
|
+
* the sentence documenting the very rule it enforces — the failure shape
|
|
166
|
+
* `./version-carriers.mjs` records for its own record-layer exclusion.
|
|
167
|
+
*
|
|
168
|
+
* Whole-line comments only. A trailing `#` cannot be stripped without knowing whether it is inside a
|
|
169
|
+
* quote, and a half-right stripper fails OPEN — it would delete a real command that happened to carry
|
|
170
|
+
* a `#`. Named as a limit rather than approximated.
|
|
171
|
+
*/
|
|
172
|
+
export const stripShellComments = (source) =>
|
|
173
|
+
source
|
|
174
|
+
.split("\n")
|
|
175
|
+
.filter((line) => !/^\s*#/.test(line))
|
|
176
|
+
.join("\n");
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Does this recipe's script reach a network mode?
|
|
180
|
+
*
|
|
181
|
+
* Deliberately whole-body rather than same-line: a recipe invokes a tool across several continued
|
|
182
|
+
* lines (`review-loop.sh` spreads one command over four), so a same-line rule would miss exactly the
|
|
183
|
+
* spelling a real invocation uses.
|
|
184
|
+
*/
|
|
185
|
+
/**
|
|
186
|
+
* Split a shell body into the tokens a command is actually made of, stripping the quoting and
|
|
187
|
+
* bracketing a path picks up in a script.
|
|
188
|
+
*/
|
|
189
|
+
const shellTokens = (body) => body.split(/\s+/).map((t) => t.replace(/^[("'`]+/, "").replace(/[)"'`;]+$/, "")).filter(Boolean);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Does this token name that module, however it is spelled?
|
|
193
|
+
*
|
|
194
|
+
* **A matcher that knew one spelling was a bypass, not a rail.** The first version compared the body
|
|
195
|
+
* against the literal `cli/telemetry.mjs`, so `node ./cli/telemetry.mjs --export` — the same file, the
|
|
196
|
+
* same effect, one prefix different — walked straight past an enforcement check. That is precisely the
|
|
197
|
+
* class milestone 8's clauses (a) and (b) exist for: a matcher's coverage is measured, never described,
|
|
198
|
+
* and seven of the eight bypasses that produced those clauses were path and grammar spellings.
|
|
199
|
+
* Copilot round 3 on #362, and https://github.com/sleepy-panda-srl/portulan/issues/337 already names
|
|
200
|
+
* `./` as a spelling that matches nothing at runtime.
|
|
201
|
+
*
|
|
202
|
+
* So the test is a **suffix** on a path-separator boundary: `cli/telemetry.mjs`, `./cli/telemetry.mjs`
|
|
203
|
+
* and `/abs/where/ever/cli/telemetry.mjs` all name it. The cost is stated rather than hidden — some
|
|
204
|
+
* *other* tree's `vendor/cli/telemetry.mjs` would match too, which is a **false red**, the safe
|
|
205
|
+
* direction, and a recipe reaching into another tree is worth a human looking either way.
|
|
206
|
+
*/
|
|
207
|
+
const tokenNamesModule = (token, module) => token === module || token.endsWith(`/${module}`);
|
|
208
|
+
|
|
209
|
+
/** A flag token, in the two spellings a shell writes it: bare, or `--flag=value`. */
|
|
210
|
+
const tokenIsFlag = (token, flag) => token === flag || token.startsWith(`${flag}=`);
|
|
211
|
+
|
|
212
|
+
export function auditRecipeSource(source) {
|
|
213
|
+
const tokens = shellTokens(stripShellComments(source));
|
|
214
|
+
return NETWORK_MODES.filter((m) => tokens.some((t) => tokenNamesModule(t, m.module)) && tokens.some((t) => tokenIsFlag(t, m.flag)));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Sweep the recipe set the workspace **yields**, at a pinned root.
|
|
219
|
+
*
|
|
220
|
+
* Three properties, each from a defect this repository has already paid for:
|
|
221
|
+
*
|
|
222
|
+
* * **It reads the yielded set, not the directory.** `../.portulan/dod.md` condition 1 is about what
|
|
223
|
+
* the manifest yields, which since milestone 7's composition amendment includes a composed pack's
|
|
224
|
+
* recipes — `tools/github:actions-pinned` lives under `packs/`. A directory sweep of
|
|
225
|
+
* `.portulan/verify/` would exempt precisely the recipes hardest to see.
|
|
226
|
+
* * **An empty or unobtainable set is could-not-run, never green.** *No recipe reaches the network*
|
|
227
|
+
* is satisfied vacuously by no recipes, and a rail whose green can be produced by an enumeration
|
|
228
|
+
* coming back empty is the fail-open
|
|
229
|
+
* `../.portulan/memory/verify-preconditions-fail-closed.md` was written about — and the vacuity
|
|
230
|
+
* `./skill-goldens.mjs` refuses one clause over.
|
|
231
|
+
* * **The root is pinned**, so the answer is about this tree rather than about the machine.
|
|
232
|
+
*/
|
|
233
|
+
export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
234
|
+
// **The workspace directory is pinned by `--repo-root` like every other path here.** It was
|
|
235
|
+
// resolved against the CURRENT WORKING DIRECTORY, so a run from outside the tree that named
|
|
236
|
+
// `--repo-root` correctly would still look for the manifest beside wherever the caller happened to
|
|
237
|
+
// stand — the same root/cwd split https://github.com/sleepy-panda-srl/portulan/issues/347 names for
|
|
238
|
+
// `--pack-root`, arriving in a new tool. `path.resolve` leaves an absolute `--workspace` alone, so
|
|
239
|
+
// naming one explicitly still wins. Copilot round 1 on #362.
|
|
240
|
+
const workspace = path.resolve(repoRoot, workspaceDir);
|
|
241
|
+
// **And it must be INSIDE the pinned root.** Resolving against `repoRoot` does not keep it there:
|
|
242
|
+
// `--workspace ../../elsewhere`, or an absolute path anywhere, would have this audit grade a
|
|
243
|
+
// manifest that is not part of the tree it claims to be answering about. Round 3 closed exactly
|
|
244
|
+
// this for a recipe's *script* and left the *manifest* open — the same fix owed at two sites and
|
|
245
|
+
// taken at one, which is now the fourth instance of that shape in this file. Copilot round 10.
|
|
246
|
+
if (!isInside(path.resolve(repoRoot), workspace)) {
|
|
247
|
+
return { ok: false, why: `the workspace at ${workspace} resolves outside the repository at ${repoRoot}; this audit grades the pinned tree and will not read past it` };
|
|
248
|
+
}
|
|
249
|
+
// **And the pack roots, which the first repair of this defect left behind.** Round 1 pinned
|
|
250
|
+
// `workspaceDir` against `repoRoot` and passed `packRoots` through untouched, so a run from outside
|
|
251
|
+
// the tree still resolved them against the caller's cwd — and the yielded recipe SET, hence this
|
|
252
|
+
// audit's whole answer, moved with where the tool was invoked from. That is `0020`'s class exactly:
|
|
253
|
+
// a fix applied at the site it was found and not at its sibling, inside the change that made it.
|
|
254
|
+
// Copilot round 6 on #362.
|
|
255
|
+
const roots = packRoots.map((r) => path.resolve(repoRoot, r));
|
|
256
|
+
let manifest;
|
|
257
|
+
try {
|
|
258
|
+
manifest = JSON.parse(fs.readFileSync(path.join(workspace, "workspace.json"), "utf8"));
|
|
259
|
+
} catch (cause) {
|
|
260
|
+
return { ok: false, why: `the workspace manifest at ${workspace} could not be read — ${cause.message}` };
|
|
261
|
+
}
|
|
262
|
+
let set;
|
|
263
|
+
try {
|
|
264
|
+
set = recipeSet(manifest, { resolve: resolverFor({ workspaceDir: workspace, manifest, repoRoot, named: roots, discovery: null, forced: false }) });
|
|
265
|
+
} catch (cause) {
|
|
266
|
+
return { ok: false, why: cause.message };
|
|
267
|
+
}
|
|
268
|
+
if (!set.ok) return { ok: false, why: set.reason };
|
|
269
|
+
const recipes = set.recipes ?? [];
|
|
270
|
+
if (recipes.length === 0) {
|
|
271
|
+
return { ok: false, why: "the workspace yielded NO recipes — an empty set makes this audit vacuously green, which is the one answer it may never give" };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const findings = [];
|
|
275
|
+
const read = [];
|
|
276
|
+
for (const r of recipes) {
|
|
277
|
+
// The script is the `.sh` token in the run line — `./.portulan/verify/docs.sh` for a
|
|
278
|
+
// workspace recipe, `bash packs/.../actions-pinned.sh` for a composed one.
|
|
279
|
+
const script = String(r.run ?? "")
|
|
280
|
+
.split(/\s+/)
|
|
281
|
+
.find((t) => t.endsWith(".sh"));
|
|
282
|
+
if (!script) {
|
|
283
|
+
return { ok: false, why: `recipe ${JSON.stringify(r.id)} has no readable script in its run line (${JSON.stringify(r.run ?? null)}); this audit cannot grade what it cannot read` };
|
|
284
|
+
}
|
|
285
|
+
const file = path.resolve(repoRoot, script);
|
|
286
|
+
// **A script outside the tree is could-not-run, never a pass.** `path.resolve` happily follows
|
|
287
|
+
// `bash ../../somewhere/outside.sh` or an absolute path out of the repository, and this audit
|
|
288
|
+
// would then read and grade a file the pinned root does not cover — ending the property the
|
|
289
|
+
// pinned root exists to buy, and offering a bypass to anyone who wanted one. `isInside` was
|
|
290
|
+
// already imported into this module for the consent check and this call site did not use it.
|
|
291
|
+
// Copilot round 3 on #362.
|
|
292
|
+
if (!isInside(path.resolve(repoRoot), file)) {
|
|
293
|
+
return { ok: false, why: `recipe ${JSON.stringify(r.id)} names ${script}, which resolves outside the repository at ${repoRoot}; this audit grades the tree and will not read past it` };
|
|
294
|
+
}
|
|
295
|
+
let source;
|
|
296
|
+
try {
|
|
297
|
+
source = fs.readFileSync(file, "utf8");
|
|
298
|
+
} catch (cause) {
|
|
299
|
+
return { ok: false, why: `recipe ${JSON.stringify(r.id)} names ${script}, which could not be read — ${cause.message}` };
|
|
300
|
+
}
|
|
301
|
+
read.push(r.id);
|
|
302
|
+
for (const hit of auditRecipeSource(source)) findings.push({ recipe: r.id, script, ...hit });
|
|
303
|
+
}
|
|
304
|
+
return { ok: true, examined: read, findings };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------------------------
|
|
308
|
+
// The config — read, validated, and never guessed at
|
|
309
|
+
// ---------------------------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
/** The config document's own version, so a future shape change is a refusal rather than a misread. */
|
|
312
|
+
export const CONFIG_SPEC = "1";
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Validate an opt-in config. Returns an array of problems; empty means usable.
|
|
316
|
+
*
|
|
317
|
+
* **Every problem is a refusal, never a default.** A config that half-parses is the state in which
|
|
318
|
+
* an author believes they opted in and did not — so there is no "assume off and carry on" arm here.
|
|
319
|
+
* Off is a value this file requires somebody to have written down.
|
|
320
|
+
*/
|
|
321
|
+
export function validateConfig(config) {
|
|
322
|
+
const problems = [];
|
|
323
|
+
if (config === null || typeof config !== "object" || Array.isArray(config)) {
|
|
324
|
+
return ["the config is not a JSON object"];
|
|
325
|
+
}
|
|
326
|
+
const spec = config.portulan?.telemetry;
|
|
327
|
+
if (spec !== CONFIG_SPEC) {
|
|
328
|
+
problems.push(`portulan.telemetry must be ${JSON.stringify(CONFIG_SPEC)}, not ${JSON.stringify(spec ?? null)}`);
|
|
329
|
+
}
|
|
330
|
+
// Absent is refused rather than read as false. An opt-in whose off state can be produced by
|
|
331
|
+
// FORGETTING a key is an opt-in that can be turned on by forgetting a different one.
|
|
332
|
+
if (typeof config.enabled !== "boolean") {
|
|
333
|
+
problems.push(`enabled must be a boolean — absent is not false, because a gate you can reach by omission is not a gate (got ${JSON.stringify(config.enabled ?? null)})`);
|
|
334
|
+
}
|
|
335
|
+
if (typeof config.service?.name !== "string" || config.service.name.length === 0) {
|
|
336
|
+
problems.push("service.name must be a non-empty string — it becomes the `service.name` resource attribute");
|
|
337
|
+
}
|
|
338
|
+
if (config.service?.namespace !== undefined && typeof config.service.namespace !== "string") {
|
|
339
|
+
problems.push("service.namespace, where present, must be a string");
|
|
340
|
+
}
|
|
341
|
+
if (!Array.isArray(config.signals) || config.signals.length === 0) {
|
|
342
|
+
problems.push("signals must be a non-empty array — an emitter with no signal is a tool that exits 0 having done nothing");
|
|
343
|
+
} else {
|
|
344
|
+
for (const s of config.signals) {
|
|
345
|
+
if (!Object.prototype.hasOwnProperty.call(PRODUCERS, s)) {
|
|
346
|
+
problems.push(`signals names ${JSON.stringify(s)}, which no producer answers to — known: ${Object.keys(PRODUCERS).join(", ")}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// The seam, as a refusal rather than as a review note. A committed file may name where a secret
|
|
351
|
+
// is read from; it may never carry one. `../.portulan/dod.md` condition 5.
|
|
352
|
+
for (const banned of ["headers", "endpoint", "token", "apiKey", "api_key"]) {
|
|
353
|
+
if (config[banned] !== undefined || config.exporter?.[banned] !== undefined) {
|
|
354
|
+
problems.push(
|
|
355
|
+
`${banned} may not be set in a committed config — transport and secrets come from the ` +
|
|
356
|
+
"OTEL_EXPORTER_OTLP_* environment, so a token never enters this repository",
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return problems;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------------------------
|
|
364
|
+
// The producer registry — the closed payload, as code
|
|
365
|
+
// ---------------------------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* What each signal contributes, and nothing else reaches a request body.
|
|
369
|
+
*
|
|
370
|
+
* **This registry IS the allow-list.** A producer returns metric rows built from named scalars; no
|
|
371
|
+
* producer is handed a whole snapshot to spread into a payload, so there is no path from a
|
|
372
|
+
* free-text or path-shaped field to the network. That is the seam holding by construction rather
|
|
373
|
+
* than by a reviewer noticing — `../.portulan/dod.md` condition 5.
|
|
374
|
+
*
|
|
375
|
+
* **Ruled 2026-08-28: the review-loop figures only.** `../.portulan/verify/review-loop.sh` says the
|
|
376
|
+
* OTel clause *"owes an emission path for these figures"*, and that debt is this one signal. The
|
|
377
|
+
* shape below is the seam a second rail joins through — a producer is one entry returning rows —
|
|
378
|
+
* rather than a promise that one day it might be.
|
|
379
|
+
*
|
|
380
|
+
* Each row: `{name, unit, description, value}`. `value` is a finite number or `null`; a null row is
|
|
381
|
+
* DROPPED rather than emitted as zero, because an unmeasured window is not a window measuring zero
|
|
382
|
+
* and `./review-meter.mjs` is careful about exactly that distinction.
|
|
383
|
+
*/
|
|
384
|
+
export const PRODUCERS = {
|
|
385
|
+
"review-loop": {
|
|
386
|
+
/** Where this producer's input is read from, relative to the repository root. */
|
|
387
|
+
input: "evals/review-loop/snapshot.json",
|
|
388
|
+
scope: "portulan/review-loop",
|
|
389
|
+
/**
|
|
390
|
+
* The instant these figures are ABOUT, which is not the instant they are sent.
|
|
391
|
+
*
|
|
392
|
+
* OTLP defines `timeUnixNano` as the time the measurement is associated with, so a snapshot's
|
|
393
|
+
* own `captured` stamp is the correct value and not a workaround for determinism — though it
|
|
394
|
+
* is also what makes the committed golden byte-stable, since a clock read would make every
|
|
395
|
+
* render differ from the last.
|
|
396
|
+
*/
|
|
397
|
+
capturedAt: (snapshot) => snapshot.captured ?? null,
|
|
398
|
+
/**
|
|
399
|
+
* **The input is validated before it is metered, and this was missing.**
|
|
400
|
+
*
|
|
401
|
+
* `meter()` treats a non-array `pullRequests` as `[]` and this producer defaults `repository`
|
|
402
|
+
* and `window.merged`, so a malformed snapshot rendered a payload carrying *wrong* window and
|
|
403
|
+
* repository metadata rather than refusing — a fail-open in the one direction that matters,
|
|
404
|
+
* since the payload is the thing that leaves the machine. `./review-meter.mjs` already exports
|
|
405
|
+
* the validator its own recipe uses; reusing it keeps one carrier of what a snapshot must be,
|
|
406
|
+
* rather than a second opinion here that could drift from the producer's. Copilot round 1 on
|
|
407
|
+
* #362, through the suppressed channel and promoted to a gating thread by this repository's own
|
|
408
|
+
* step.
|
|
409
|
+
*/
|
|
410
|
+
validate: (snapshot) => validateSnapshot(snapshot),
|
|
411
|
+
rows(snapshot) {
|
|
412
|
+
const m = meter(snapshot);
|
|
413
|
+
return [
|
|
414
|
+
{ name: "portulan.review.pull_requests", unit: "{pull_request}", description: "Merged pull requests in the metered window.", value: m.pullRequests },
|
|
415
|
+
{ name: "portulan.review.submissions", unit: "{submission}", description: "Reviews the reviewer submitted across the window. Submission units, never fix-rounds.", value: m.submissions },
|
|
416
|
+
{ name: "portulan.review.submissions_per_pull_request", unit: "1", description: "The figure a-review-loop-needs-a-bound.md bounds the loop on.", value: m.submissionsPerPullRequest },
|
|
417
|
+
{ name: "portulan.review.submissions_no_inline", unit: "{submission}", description: "Submissions raising no inline comment.", value: m.noInline },
|
|
418
|
+
{ name: "portulan.review.no_inline_rate", unit: "1", description: "An UPPER BOUND on the found-nothing rate; a submission carrying only suppressed notes counts in it.", value: m.noInlineRate },
|
|
419
|
+
{ name: "portulan.review.pushes", unit: "{push}", description: "Distinct heads the reviewer saw — a floor on pushes, not a count of them.", value: m.pushes },
|
|
420
|
+
{ name: "portulan.review.pushes_per_submission", unit: "1", description: "The criterion's literal pushes-per-round figure, in submission units.", value: m.pushesPerSubmission },
|
|
421
|
+
];
|
|
422
|
+
},
|
|
423
|
+
/**
|
|
424
|
+
* Attributes every row of this signal carries.
|
|
425
|
+
*
|
|
426
|
+
* `window.captured` is here because of a defect one rail over: the review-loop snapshot **does
|
|
427
|
+
* not refresh itself** (https://github.com/sleepy-panda-srl/portulan/issues/356), so a stale
|
|
428
|
+
* capture and a current one are the same green. Stamping the capture date onto the exported
|
|
429
|
+
* data means a stale export is LABELLED stale at the far end rather than arriving as though it
|
|
430
|
+
* were fresh — which is the most this emitter can do about a silence it does not own.
|
|
431
|
+
*/
|
|
432
|
+
attributes: (snapshot) => ({
|
|
433
|
+
"portulan.window.merged": snapshot.window?.merged ?? 0,
|
|
434
|
+
"portulan.window.captured": snapshot.captured ?? "",
|
|
435
|
+
"portulan.units": "submission",
|
|
436
|
+
}),
|
|
437
|
+
/**
|
|
438
|
+
* The one identifier the payload carries, and it is the maintainer's widening rather than an
|
|
439
|
+
* implementer's.
|
|
440
|
+
*
|
|
441
|
+
* The ruled payload vocabulary was *figures, rule ids, recipe ids* — no identity at all. The
|
|
442
|
+
* supervisor's objection was that an export with no resource identity is indistinguishable at
|
|
443
|
+
* any real collector, so somebody adds one later, and a closed list widened by an implementer
|
|
444
|
+
* is the failure the list exists to prevent. Put to him 2026-08-28; **he widened it to one
|
|
445
|
+
* named resource attribute**, which is this.
|
|
446
|
+
*
|
|
447
|
+
* A **resource** attribute rather than a data-point one, because it identifies the source and
|
|
448
|
+
* not the measurement — and it rides once per payload instead of once per point.
|
|
449
|
+
*
|
|
450
|
+
* `login` stays excluded and is the field this deliberately is not. This snapshot's only login
|
|
451
|
+
* is a bot's, but an adopter's would carry human reviewer names, and an identifier about a
|
|
452
|
+
* PERSON is outside anything ruled here.
|
|
453
|
+
*/
|
|
454
|
+
resource: (snapshot) => ({ "portulan.repository": snapshot.repository ?? "" }),
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Every attribute key an emission **may** carry, pinned so the closed payload is a rail and not a
|
|
460
|
+
* reminder.
|
|
461
|
+
*
|
|
462
|
+
* **An unfamiliar key in a rendered payload is a red.** Without this the "closed list" is a promise
|
|
463
|
+
* about the code rather than a property of it — and a producer added later could widen the vocabulary
|
|
464
|
+
* silently, which is precisely the widening the maintainer reserved to himself.
|
|
465
|
+
*
|
|
466
|
+
* **This is an ALLOW-LIST, and `REQUIRED_ATTRIBUTE_KEYS` below is the floor — one list was doing both
|
|
467
|
+
* jobs and they are not the same job.** `service.namespace` is optional in the config *and* optional
|
|
468
|
+
* in OpenTelemetry's own semantic conventions, so a workspace that declares none emits a payload
|
|
469
|
+
* without it — legitimately. A single list checked "in both directions" made that legal config look
|
|
470
|
+
* like a broken invariant, and the only reason nothing failed is that THIS workspace happens to
|
|
471
|
+
* declare a namespace: the pin was green by coincidence of one instance. Requiring the key instead
|
|
472
|
+
* would have been this repository inventing a constraint the specification does not have. Copilot
|
|
473
|
+
* round 2 on #362.
|
|
474
|
+
*/
|
|
475
|
+
export const EMITTED_ATTRIBUTE_KEYS = Object.freeze([
|
|
476
|
+
"service.name",
|
|
477
|
+
"service.namespace",
|
|
478
|
+
"telemetry.sdk.name",
|
|
479
|
+
"telemetry.sdk.language",
|
|
480
|
+
"telemetry.sdk.version",
|
|
481
|
+
"portulan.repository",
|
|
482
|
+
"portulan.window.merged",
|
|
483
|
+
"portulan.window.captured",
|
|
484
|
+
"portulan.units",
|
|
485
|
+
]);
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* The keys every emission **must** carry, whatever the config says.
|
|
489
|
+
*
|
|
490
|
+
* The floor rather than the ceiling: a payload missing one of these is not identifiable at a
|
|
491
|
+
* collector, which is the thing the maintainer's one widening of the vocabulary was for. Everything in
|
|
492
|
+
* `EMITTED_ATTRIBUTE_KEYS` and not here is legitimately conditional — today that is `service.namespace`
|
|
493
|
+
* alone, and it is conditional on the config declaring one.
|
|
494
|
+
*/
|
|
495
|
+
export const REQUIRED_ATTRIBUTE_KEYS = Object.freeze(EMITTED_ATTRIBUTE_KEYS.filter((k) => k !== "service.namespace"));
|
|
496
|
+
|
|
497
|
+
// ---------------------------------------------------------------------------------------------
|
|
498
|
+
// OTLP/HTTP JSON — the wire shape, written by hand
|
|
499
|
+
// ---------------------------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* One OTLP `AnyValue`. Exported so the suite asserts against THIS map rather than re-spelling it —
|
|
503
|
+
* a second copy of a wire encoding is a second thing to get wrong, and `./inside.mjs`'s docblock is
|
|
504
|
+
* this repository's standing argument about that.
|
|
505
|
+
*/
|
|
506
|
+
export function anyValue(v) {
|
|
507
|
+
if (typeof v === "string") return { stringValue: v };
|
|
508
|
+
if (typeof v === "boolean") return { boolValue: v };
|
|
509
|
+
// **`isSafeInteger`, not `isInteger`.** OTLP's `intValue` is an int64 and a JS number stops being an
|
|
510
|
+
// exact integer past 2^53 — `Number.isInteger(2**53 + 1)` is `true` and the value is already the
|
|
511
|
+
// wrong one, so stringifying it emits a precise-looking integer that is not the number anybody
|
|
512
|
+
// meant. Beyond that range `asDouble` is the honest encoding: approximate, and *labelled*
|
|
513
|
+
// approximate, which is the same distinction between could-not-run and a verdict that this module
|
|
514
|
+
// spends a docblock on. Copilot round 11 on #362, through the suppressed channel.
|
|
515
|
+
//
|
|
516
|
+
// **Not reachable with today's producer** — the review-loop figures are pull-request and submission
|
|
517
|
+
// counts — and it is fixed anyway because `anyValue` is exported, and the producer seam exists to
|
|
518
|
+
// invite rows this module did not write.
|
|
519
|
+
if (Number.isSafeInteger(v)) return { intValue: String(v) };
|
|
520
|
+
if (typeof v === "number" && Number.isFinite(v)) return { doubleValue: v };
|
|
521
|
+
throw new Error(`no OTLP encoding for ${JSON.stringify(v)}`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const kv = (attrs) => Object.entries(attrs).map(([key, value]) => ({ key, value: anyValue(value) }));
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Render the OTLP/HTTP JSON metrics payload.
|
|
528
|
+
*
|
|
529
|
+
* **Gauge, and the point type is a decision rather than a default.** These are snapshot statistics
|
|
530
|
+
* over a window of merged pull requests — not monotonic counters. A `Sum` would owe an
|
|
531
|
+
* `aggregationTemporality` and a `startTimeUnixNano`, and the snapshot records no window START (only
|
|
532
|
+
* its size and the per-pull-request merge stamps), so any start instant would be derived — which is
|
|
533
|
+
* where double counting comes from. A Gauge needs neither and says what these figures are.
|
|
534
|
+
*/
|
|
535
|
+
export function renderPayload({ config, signals, version = VERSION }) {
|
|
536
|
+
const resource = { "service.name": config.service.name, "telemetry.sdk.name": "portulan", "telemetry.sdk.language": "nodejs", "telemetry.sdk.version": version };
|
|
537
|
+
if (config.service.namespace !== undefined) resource["service.namespace"] = config.service.namespace;
|
|
538
|
+
// The maintainer's one widening of the closed vocabulary, contributed by the signal rather than
|
|
539
|
+
// invented here. Signals agreeing on a key is fine; a signal inventing one the pin does not know
|
|
540
|
+
// is what `EMITTED_ATTRIBUTE_KEYS` and the suite exist to catch.
|
|
541
|
+
for (const s of signals) Object.assign(resource, s.resource ?? {});
|
|
542
|
+
|
|
543
|
+
const scopeMetrics = [];
|
|
544
|
+
for (const s of signals) {
|
|
545
|
+
const timeUnixNano = String(BigInt(Date.parse(s.capturedAt)) * 1000000n);
|
|
546
|
+
const attributes = kv(s.attributes);
|
|
547
|
+
const metrics = s.rows
|
|
548
|
+
// A null figure is DROPPED, never sent as zero. `./review-meter.mjs` returns null for an
|
|
549
|
+
// unmeasured ratio precisely so nobody reads an empty corpus as a measured zero, and
|
|
550
|
+
// encoding it as 0 here would undo that one layer down.
|
|
551
|
+
.filter((r) => r.value !== null && Number.isFinite(r.value))
|
|
552
|
+
.map((r) => ({
|
|
553
|
+
name: r.name,
|
|
554
|
+
unit: r.unit,
|
|
555
|
+
description: r.description,
|
|
556
|
+
gauge: { dataPoints: [{ timeUnixNano, attributes, ...(Number.isSafeInteger(r.value) ? { asInt: String(r.value) } : { asDouble: r.value }) }] },
|
|
557
|
+
}));
|
|
558
|
+
scopeMetrics.push({ scope: { name: s.scope, version }, metrics });
|
|
559
|
+
}
|
|
560
|
+
return { resourceMetrics: [{ resource: { attributes: kv(resource) }, scopeMetrics }] };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** The bytes on the wire and in the golden are the same bytes, produced here and nowhere else. */
|
|
564
|
+
export const serialize = (payload) => `${JSON.stringify(payload, null, 2)}\n`;
|
|
565
|
+
|
|
566
|
+
// ---------------------------------------------------------------------------------------------
|
|
567
|
+
// Transport — the ONE mode that reaches the network
|
|
568
|
+
// ---------------------------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Read transport from the OpenTelemetry standard environment. Nothing here is committed anywhere.
|
|
572
|
+
*
|
|
573
|
+
* `OTEL_EXPORTER_OTLP_HEADERS` is the specification's `key=value,key=value` list; it is parsed but
|
|
574
|
+
* **never printed**, because a header list is where a bearer token lives. Neither is the endpoint,
|
|
575
|
+
* beyond its origin and path — see `safeEndpoint`, and round 14 of this file's own review for why
|
|
576
|
+
* withholding one while printing the other was not enough.
|
|
577
|
+
*/
|
|
578
|
+
/**
|
|
579
|
+
* A URL safe to print: no userinfo, no query, no fragment.
|
|
580
|
+
*
|
|
581
|
+
* **The headers were withheld and the URL was not, which is the same reasoning applied at one site and
|
|
582
|
+
* not its sibling.** A collector endpoint legally carries credentials — `https://user:pass@host/…` —
|
|
583
|
+
* and tokens are routinely passed as query parameters, so printing the endpoint on success leaked
|
|
584
|
+
* exactly what withholding the header list was protecting. Into CI logs, which are long-lived and, in
|
|
585
|
+
* this repository, world-readable.
|
|
586
|
+
*
|
|
587
|
+
* Origin and path only. Enough for a human to tell *where it went*, which is the whole reason the
|
|
588
|
+
* endpoint is printed at all. Copilot round 14 on #362.
|
|
589
|
+
*/
|
|
590
|
+
export function safeEndpoint(href) {
|
|
591
|
+
try {
|
|
592
|
+
const u = new URL(href);
|
|
593
|
+
u.username = "";
|
|
594
|
+
u.password = "";
|
|
595
|
+
u.search = "";
|
|
596
|
+
u.hash = "";
|
|
597
|
+
return u.href;
|
|
598
|
+
} catch {
|
|
599
|
+
// Unparsable, so nothing can be redacted from it with any confidence — and an unparsable value
|
|
600
|
+
// is exactly the one most likely to be a mistyped secret. Never echoed.
|
|
601
|
+
return "<withheld: not a parsable URL>";
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export function transportFromEnv(env) {
|
|
606
|
+
// **The signal-specific variables win, and they are used AS GIVEN.** The OpenTelemetry protocol
|
|
607
|
+
// specification draws a distinction this function ignored: `OTEL_EXPORTER_OTLP_ENDPOINT` is a BASE
|
|
608
|
+
// to which the signal's path is appended, while `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` is the full
|
|
609
|
+
// URL and must be used untouched. Appending to both produced `…/v1/metrics/v1/metrics` for anyone
|
|
610
|
+
// configured the signal-specific way, and left them no escape hatch — while this file's own
|
|
611
|
+
// docblock claimed the variables are read "exactly as the OpenTelemetry specification defines
|
|
612
|
+
// them". A conformance claim is a claim like any other, and this one was false. Copilot round 5 on
|
|
613
|
+
// #362, through the suppressed channel.
|
|
614
|
+
const specific = env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT;
|
|
615
|
+
const base = env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
616
|
+
const named = typeof specific === "string" && specific.length > 0 ? "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" : "OTEL_EXPORTER_OTLP_ENDPOINT";
|
|
617
|
+
const raw = named === "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" ? specific : base;
|
|
618
|
+
if (typeof raw !== "string" || raw.length === 0) {
|
|
619
|
+
return { ok: false, why: "neither OTEL_EXPORTER_OTLP_METRICS_ENDPOINT nor OTEL_EXPORTER_OTLP_ENDPOINT is set" };
|
|
620
|
+
}
|
|
621
|
+
let url;
|
|
622
|
+
try {
|
|
623
|
+
url = new URL(named === "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" ? raw : raw.endsWith("/") ? `${raw}v1/metrics` : `${raw}/v1/metrics`);
|
|
624
|
+
} catch {
|
|
625
|
+
// The raw value is NOT echoed. It comes from the environment, it may carry credentials or a
|
|
626
|
+
// query token, and a value that failed to parse is the likeliest of all to be a mistyped
|
|
627
|
+
// secret. The variable name is what a reader needs; the value is what they must not be given.
|
|
628
|
+
return { ok: false, why: `${named} is not a parsable URL — its value is withheld, since an endpoint may carry credentials` };
|
|
629
|
+
}
|
|
630
|
+
// Headers follow the same rule: the signal-specific list REPLACES the general one rather than
|
|
631
|
+
// merging with it, which is what the specification says and is the behaviour a reader configuring
|
|
632
|
+
// one tenant's credentials for metrics alone is relying on.
|
|
633
|
+
const headerSource = env.OTEL_EXPORTER_OTLP_METRICS_HEADERS ?? env.OTEL_EXPORTER_OTLP_HEADERS ?? "";
|
|
634
|
+
const headers = { "content-type": "application/json" };
|
|
635
|
+
for (const pair of headerSource.split(",")) {
|
|
636
|
+
const at = pair.indexOf("=");
|
|
637
|
+
if (at > 0) headers[pair.slice(0, at).trim().toLowerCase()] = pair.slice(at + 1).trim();
|
|
638
|
+
}
|
|
639
|
+
return { ok: true, url: url.href, headers, from: named };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* The real send. **Injected everywhere it is exercised**, and that is a rule rather than a
|
|
644
|
+
* convenience: `../.portulan/verify/tests.sh` runs the suite, so a test opening a real socket would
|
|
645
|
+
* be a network call inside a verify recipe — which `../spec/slots.md` and
|
|
646
|
+
* `../.portulan/verify/README.md` both prohibit outright. The suite tests this module against an
|
|
647
|
+
* injected transport; the real socket is exercised in the session's recorded demonstration, outside
|
|
648
|
+
* the recipe set.
|
|
649
|
+
*/
|
|
650
|
+
export async function postJson(url, headers, body) {
|
|
651
|
+
const res = await fetch(url, { method: "POST", headers, body });
|
|
652
|
+
return { status: res.status, ok: res.ok, text: await res.text().catch(() => "") };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Is the consent COMMITTED, or merely present in somebody's working copy?
|
|
657
|
+
*
|
|
658
|
+
* **The ruling is that the committed config is the standing consent — so an uncommitted config is not
|
|
659
|
+
* consent at all.** Without this check an agent could write `enabled: true` into a working copy and
|
|
660
|
+
* export on nobody's decision, and the maintainer's Gated act would have been performed by the thing
|
|
661
|
+
* it gates. That is a mandate nothing checks, which this repository holds to be another way of
|
|
662
|
+
* spelling broken (`../.portulan/memory/a-mandate-nothing-checks-is-already-broken.md`), arriving
|
|
663
|
+
* inside the change that states the mandate.
|
|
664
|
+
*
|
|
665
|
+
* The discipline is `./drills.mjs`'s, one file wide: it **reports on a commit and says which one**.
|
|
666
|
+
* Tracked, and byte-identical to `HEAD`, or `--export` refuses.
|
|
667
|
+
*
|
|
668
|
+
* **This is the ONLY path in this module that spawns anything**, and it is on the `--export` arm
|
|
669
|
+
* alone — `--render` and `--check` are what a verify recipe may run, and they touch neither a
|
|
670
|
+
* subprocess nor a socket.
|
|
671
|
+
*/
|
|
672
|
+
export function consentIsCommitted(configPath, repoRoot, spawn = spawnSync) {
|
|
673
|
+
const parent = path.resolve(repoRoot);
|
|
674
|
+
// **Pinned by `repoRoot`, like every other path this tool takes.** `path.resolve(configPath)` alone
|
|
675
|
+
// reads a relative `--config` against the CALLER'S cwd, so an in-repo config was classed *outside
|
|
676
|
+
// the repository* whenever the tool ran from anywhere else. Third instance of the root/cwd split in
|
|
677
|
+
// this one file — `workspaceDir` in round 1, `packRoots` in round 6, this in round 7 — which is why
|
|
678
|
+
// it is fixed here as a rule rather than a site: every path this function touches derives from
|
|
679
|
+
// `child`, and nothing below calls `path.resolve` on the raw argument again.
|
|
680
|
+
const child = path.resolve(parent, configPath);
|
|
681
|
+
if (!isInside(parent, child)) {
|
|
682
|
+
return { ok: false, why: `${configPath} is outside the repository at ${repoRoot}, so nothing can establish that it is committed` };
|
|
683
|
+
}
|
|
684
|
+
// **Git wants POSIX separators, whatever the platform gives `path.relative`.** On Windows this
|
|
685
|
+
// returns `evals\\telemetry\\config.json`, and git's pathspecs and `HEAD:<path>` syntax both want
|
|
686
|
+
// `/` — so a config that IS committed would be refused as untracked, or as missing at HEAD. The
|
|
687
|
+
// consent gate would fail closed rather than open, but it would fail for every Windows adopter and
|
|
688
|
+
// the message would name the wrong cause.
|
|
689
|
+
//
|
|
690
|
+
// Seven sites in `cli/` already normalise this way, `./librarian.mjs` on exactly this
|
|
691
|
+
// git-relative case; this was the eighth that needed it and did not have it. Unlike `isInside`,
|
|
692
|
+
// which was extracted to one carrier, this convention is still copied — filed rather than reduced
|
|
693
|
+
// here, since collapsing seven call sites is a change of its own shape.
|
|
694
|
+
// Copilot round 9 on #362.
|
|
695
|
+
const rel = path.relative(parent, child).split(path.sep).join("/");
|
|
696
|
+
const git = (args) => spawn("git", ["-C", parent, ...args], { encoding: "utf8" });
|
|
697
|
+
|
|
698
|
+
// **Is this a repository at all? Asked ONCE, up front, and that is the whole design of what
|
|
699
|
+
// follows.**
|
|
700
|
+
//
|
|
701
|
+
// The first repair of this function distinguished *git failed* from *the file is untracked* by
|
|
702
|
+
// treating exit **128** as "not a repository". That is true of `ls-files` and **false of `show`**:
|
|
703
|
+
// `git show HEAD:<path>` also exits 128 for a path that is tracked in the index and not in `HEAD` —
|
|
704
|
+
// *"fatal: path 'x' exists on disk, but not in 'HEAD'"*, measured. So the repair made the
|
|
705
|
+
// *does not exist at HEAD* branch unreachable and reported *not a repository* for a file somebody
|
|
706
|
+
// had **staged and never committed** — which is precisely the case this gate exists to catch, since
|
|
707
|
+
// staging is how an agent would try to manufacture consent one step short of a commit.
|
|
708
|
+
//
|
|
709
|
+
// Establishing repository-ness once removes the ambiguity structurally instead of by matching on
|
|
710
|
+
// git's prose, which is localised and version-dependent. After this line, a non-zero status is that
|
|
711
|
+
// command's own domain answer and nothing else. Copilot round 7 on #362.
|
|
712
|
+
const repo = git(["rev-parse", "--git-dir"]);
|
|
713
|
+
if (repo.error) return { ok: false, why: `git could not be run to establish whether the consent is committed — ${repo.error.message}` };
|
|
714
|
+
if (repo.status !== 0) {
|
|
715
|
+
return { ok: false, why: `${parent} is not a git repository, so nothing there can be committed — ${(repo.stderr || "").trim() || "git rev-parse --git-dir failed"}` };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const tracked = git(["ls-files", "--error-unmatch", "--", rel]);
|
|
719
|
+
if (tracked.error) return { ok: false, why: `git could not be run to establish whether the consent is committed — ${tracked.error.message}` };
|
|
720
|
+
if (tracked.status !== 0) {
|
|
721
|
+
return { ok: false, why: `${rel} is not tracked by git, so it is one working copy's opinion rather than the team's committed consent` };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const head = git(["show", `HEAD:${rel}`]);
|
|
725
|
+
if (head.error) return { ok: false, why: `git could not read ${rel} at HEAD — ${head.error.message}` };
|
|
726
|
+
if (head.status !== 0) {
|
|
727
|
+
// Tracked but not in `HEAD` — staged, or committed on no branch yet. Either way the consent has
|
|
728
|
+
// never been committed, which is the answer, not a malfunction.
|
|
729
|
+
return { ok: false, why: `${rel} is tracked but does not exist at HEAD, so the consent it states has been staged and never committed` };
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// **Git's own notion of "differs", not a byte compare.** Reading the working copy and comparing it
|
|
733
|
+
// to `git show HEAD:<path>` looks equivalent and is not: with `core.autocrlf=true` — the Windows
|
|
734
|
+
// default, and this repository ships no `.gitattributes` — a committed file is checked out with
|
|
735
|
+
// CRLF while the blob at HEAD stays LF. The bytes differ, `git diff` is clean, and the gate would
|
|
736
|
+
// refuse a consent that IS committed. Fail-closed, and wrong for every Windows adopter: the same
|
|
737
|
+
// platform class as the separator defect one round earlier, and equally invisible to CI here,
|
|
738
|
+
// which runs `ubuntu-latest` only. Copilot round 16 on #362.
|
|
739
|
+
const diff = git(["diff", "--quiet", "HEAD", "--", rel]);
|
|
740
|
+
if (diff.error) return { ok: false, why: `git could not compare ${rel} against HEAD — ${diff.error.message}` };
|
|
741
|
+
if (diff.status === 1) {
|
|
742
|
+
return { ok: false, why: `${rel} differs from HEAD — an edited consent is not a committed one. Commit it, or run --render to see what WOULD be sent` };
|
|
743
|
+
}
|
|
744
|
+
if (diff.status !== 0) {
|
|
745
|
+
return { ok: false, why: `git could not compare ${rel} against HEAD — it exited ${diff.status}: ${(diff.stderr || "").trim()}` };
|
|
746
|
+
}
|
|
747
|
+
return { ok: true };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// ---------------------------------------------------------------------------------------------
|
|
751
|
+
// The command line
|
|
752
|
+
// ---------------------------------------------------------------------------------------------
|
|
753
|
+
|
|
754
|
+
const USAGE = [
|
|
755
|
+
"usage: node cli/telemetry.mjs --config <file> [--repo-root <dir>] [--render | --check <file> | --write <file> | --export]",
|
|
756
|
+
"",
|
|
757
|
+
" --config <file> the committed opt-in config; the ONLY gate on emission",
|
|
758
|
+
" --render print the OTLP/HTTP JSON payload and open no socket (the default)",
|
|
759
|
+
" --check <file> byte-compare the payload against a committed golden",
|
|
760
|
+
" --write <file> rewrite that golden",
|
|
761
|
+
" --export the ONE mode that reaches the network; refuses unless the config opts in",
|
|
762
|
+
" AND is committed. Transport comes from the OTLP environment, signal-specific",
|
|
763
|
+
" first: OTEL_EXPORTER_OTLP_METRICS_ENDPOINT (used as given) else",
|
|
764
|
+
" OTEL_EXPORTER_OTLP_ENDPOINT (a base, +/v1/metrics); and",
|
|
765
|
+
" OTEL_EXPORTER_OTLP_METRICS_HEADERS replacing OTEL_EXPORTER_OTLP_HEADERS",
|
|
766
|
+
" --audit-recipes assert that no recipe the workspace YIELDS can reach a network mode;",
|
|
767
|
+
" takes --workspace and --pack-root, and needs no --config",
|
|
768
|
+
"",
|
|
769
|
+
"exit 0 the thing asked for happened · 1 a verdict (golden drift, or export while opted out)",
|
|
770
|
+
" · 2 could not run (config or snapshot missing, unreadable, or malformed)",
|
|
771
|
+
].join("\n");
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Every option whose value is a path, and therefore must be pinned by `--repo-root`.
|
|
775
|
+
*
|
|
776
|
+
* **This exists because the same defect was fixed five times, one site at a time.** Across this pull
|
|
777
|
+
* request's review: `--workspace` resolved against the caller's cwd (round 1), `--pack-root` left
|
|
778
|
+
* behind by that very fix (round 6), `--config` and its re-read (round 7), `--workspace` resolved but
|
|
779
|
+
* not contained (round 10), and `--check`/`--write` (round 12). Each repair was correct and each left
|
|
780
|
+
* a sibling, which is
|
|
781
|
+
* `../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md`'s class five times over.
|
|
782
|
+
*
|
|
783
|
+
* So the pinning is **one operation over a declared list** rather than a call at each use. A new path
|
|
784
|
+
* option is pinned by adding it here, and the suite asserts this list against the parser's own keys —
|
|
785
|
+
* so an option that takes a path and is not named here reddens instead of becoming the sixth instance.
|
|
786
|
+
*/
|
|
787
|
+
export const PATH_OPTIONS = Object.freeze(["config", "check", "write", "workspace"]);
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Resolve every path option against the pinned root, once, before anything reads one.
|
|
791
|
+
*
|
|
792
|
+
* `path.resolve` leaves an absolute value alone, so naming an absolute path explicitly still wins —
|
|
793
|
+
* which is the property `--pack-root` needed and the reason this is `resolve` rather than `join`.
|
|
794
|
+
*/
|
|
795
|
+
export function pinPaths(opts) {
|
|
796
|
+
const root = path.resolve(opts.repoRoot);
|
|
797
|
+
const pinned = { ...opts, repoRoot: root, packRoots: opts.packRoots.map((r) => path.resolve(root, r)) };
|
|
798
|
+
for (const key of PATH_OPTIONS) {
|
|
799
|
+
if (typeof pinned[key] === "string" && pinned[key].length > 0) pinned[key] = path.resolve(root, pinned[key]);
|
|
800
|
+
}
|
|
801
|
+
return pinned;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function parseArgs(argv) {
|
|
805
|
+
const opts = { config: null, repoRoot: ".", render: false, check: null, write: null, export: false, audit: false, workspace: ".portulan", packRoots: [], help: false };
|
|
806
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
807
|
+
const a = argv[i];
|
|
808
|
+
const next = () => {
|
|
809
|
+
const v = argv[i + 1];
|
|
810
|
+
if (v === undefined) throw new Error(`${a} needs a value`);
|
|
811
|
+
i += 1;
|
|
812
|
+
return v;
|
|
813
|
+
};
|
|
814
|
+
if (a === "--config") opts.config = next();
|
|
815
|
+
else if (a === "--repo-root") opts.repoRoot = next();
|
|
816
|
+
else if (a === "--render") opts.render = true;
|
|
817
|
+
else if (a === "--check") opts.check = next();
|
|
818
|
+
else if (a === "--write") opts.write = next();
|
|
819
|
+
else if (a === "--export") opts.export = true;
|
|
820
|
+
else if (a === "--audit-recipes") opts.audit = true;
|
|
821
|
+
else if (a === "--workspace") opts.workspace = next();
|
|
822
|
+
else if (a === "--pack-root") opts.packRoots.push(next());
|
|
823
|
+
else if (a === "--help" || a === "-h") opts.help = true;
|
|
824
|
+
else throw new Error(`unrecognised argument ${JSON.stringify(a)}`);
|
|
825
|
+
}
|
|
826
|
+
return opts;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
830
|
+
|
|
831
|
+
export async function run(argv = process.argv.slice(2), io = console, { env = process.env, post = postJson } = {}) {
|
|
832
|
+
let opts;
|
|
833
|
+
try {
|
|
834
|
+
opts = parseArgs(argv);
|
|
835
|
+
} catch (e) {
|
|
836
|
+
io.error(`telemetry: ${e.message}`);
|
|
837
|
+
io.error(USAGE);
|
|
838
|
+
return 2;
|
|
839
|
+
}
|
|
840
|
+
if (opts.help) {
|
|
841
|
+
io.log(USAGE);
|
|
842
|
+
return 0;
|
|
843
|
+
}
|
|
844
|
+
// Pinned ONCE, here, before anything reads a path. See `PATH_OPTIONS`.
|
|
845
|
+
opts = pinPaths(opts);
|
|
846
|
+
// The audit is about the RECIPES, not about a payload, so it runs before the config is read and
|
|
847
|
+
// needs no --config. A workspace that has not opted in still owes the property that none of its
|
|
848
|
+
// recipes can reach the network.
|
|
849
|
+
if (opts.audit) {
|
|
850
|
+
const res = auditRecipes({ workspaceDir: opts.workspace, repoRoot: opts.repoRoot, packRoots: opts.packRoots });
|
|
851
|
+
if (!res.ok) {
|
|
852
|
+
io.error(`telemetry: the offline audit could not run — ${res.why}`);
|
|
853
|
+
return 2;
|
|
854
|
+
}
|
|
855
|
+
if (res.findings.length > 0) {
|
|
856
|
+
io.error(`telemetry: ${res.findings.length} verify recipe(s) can reach the network:`);
|
|
857
|
+
for (const f of res.findings) io.error(` - ${f.recipe} (${f.script}) invokes ${f.module} ${f.flag}, which ${f.what}`);
|
|
858
|
+
io.error("A verify recipe may not make a network call: spec/slots.md and .portulan/verify/README.md");
|
|
859
|
+
io.error("both prohibit it, because a rail that moves with the network goes red about the world");
|
|
860
|
+
io.error("rather than about the tree.");
|
|
861
|
+
return 1;
|
|
862
|
+
}
|
|
863
|
+
io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode`);
|
|
864
|
+
io.log(` Modes railed: ${NETWORK_MODES.map((m) => `${m.module} ${m.flag}`).join(", ")}.`);
|
|
865
|
+
io.log(" A network mode with no row here is UNRAILED, and nothing can audit that a row was added.");
|
|
866
|
+
return 0;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (!opts.config) {
|
|
870
|
+
io.error("telemetry: --config <file> is required — the committed config is the only gate on emission,");
|
|
871
|
+
io.error("and a run that inferred one would be an emitter deciding its own consent.");
|
|
872
|
+
return 2;
|
|
873
|
+
}
|
|
874
|
+
const modes = [opts.render, opts.check !== null, opts.write !== null, opts.export].filter(Boolean).length;
|
|
875
|
+
if (modes > 1) {
|
|
876
|
+
io.error("telemetry: --render, --check, --write and --export ask for different things; pick one");
|
|
877
|
+
return 2;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ---- the config. Unreadable and opted-out are DIFFERENT ANSWERS and get different codes.
|
|
881
|
+
// Pinned against `--repo-root`, the same rule `consentIsCommitted` and the signal inputs follow —
|
|
882
|
+
// a relative `--config` must mean the same file wherever the tool is invoked from.
|
|
883
|
+
const configPath = opts.config;
|
|
884
|
+
let config;
|
|
885
|
+
try {
|
|
886
|
+
config = readJson(configPath);
|
|
887
|
+
} catch (e) {
|
|
888
|
+
io.error(`telemetry: cannot read ${configPath} — ${e.message}`);
|
|
889
|
+
io.error("This is could-not-run, NOT opted out: a config that will not parse may well say `enabled: true`.");
|
|
890
|
+
return 2;
|
|
891
|
+
}
|
|
892
|
+
const problems = validateConfig(config);
|
|
893
|
+
if (problems.length > 0) {
|
|
894
|
+
io.error(`telemetry: ${configPath} is not a usable opt-in config:`);
|
|
895
|
+
for (const p of problems) io.error(` - ${p}`);
|
|
896
|
+
io.error("This is could-not-run, NOT opted out — a malformed config states no decision either way.");
|
|
897
|
+
return 2;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// ---- the signals
|
|
901
|
+
const signals = [];
|
|
902
|
+
for (const name of config.signals) {
|
|
903
|
+
const producer = PRODUCERS[name];
|
|
904
|
+
const input = path.resolve(opts.repoRoot, producer.input);
|
|
905
|
+
let snapshot;
|
|
906
|
+
try {
|
|
907
|
+
snapshot = readJson(input);
|
|
908
|
+
} catch (e) {
|
|
909
|
+
io.error(`telemetry: signal ${JSON.stringify(name)} cannot read its input ${input} — ${e.message}`);
|
|
910
|
+
return 2;
|
|
911
|
+
}
|
|
912
|
+
const problems = producer.validate?.(snapshot) ?? [];
|
|
913
|
+
if (problems.length > 0) {
|
|
914
|
+
io.error(`telemetry: signal ${JSON.stringify(name)} cannot be metered from ${input}:`);
|
|
915
|
+
for (const p of problems) io.error(` - ${p}`);
|
|
916
|
+
io.error("A malformed input would render a payload with defaulted metadata rather than none,");
|
|
917
|
+
io.error("and the payload is the thing that leaves the machine.");
|
|
918
|
+
return 2;
|
|
919
|
+
}
|
|
920
|
+
const capturedAt = producer.capturedAt(snapshot);
|
|
921
|
+
if (typeof capturedAt !== "string" || Number.isNaN(Date.parse(capturedAt))) {
|
|
922
|
+
io.error(`telemetry: signal ${JSON.stringify(name)} has no parsable capture stamp (${JSON.stringify(capturedAt)});`);
|
|
923
|
+
io.error("OTLP needs the instant the measurement is ABOUT, and a clock read here would be a different figure.");
|
|
924
|
+
return 2;
|
|
925
|
+
}
|
|
926
|
+
signals.push({ name, scope: producer.scope, capturedAt, rows: producer.rows(snapshot), attributes: producer.attributes(snapshot), resource: producer.resource?.(snapshot) ?? {} });
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
let body;
|
|
930
|
+
try {
|
|
931
|
+
body = serialize(renderPayload({ config, signals }));
|
|
932
|
+
} catch (e) {
|
|
933
|
+
io.error(`telemetry: the payload could not be encoded — ${e.message}`);
|
|
934
|
+
return 2;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const emitted = signals.reduce((n, s) => n + s.rows.filter((r) => r.value !== null && Number.isFinite(r.value)).length, 0);
|
|
938
|
+
|
|
939
|
+
// ---- --write
|
|
940
|
+
if (opts.write !== null) {
|
|
941
|
+
fs.mkdirSync(path.dirname(opts.write), { recursive: true });
|
|
942
|
+
fs.writeFileSync(opts.write, body);
|
|
943
|
+
io.log(`telemetry: wrote ${opts.write} — ${emitted} metric(s) over ${signals.length} signal(s)`);
|
|
944
|
+
return 0;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ---- --check
|
|
948
|
+
if (opts.check !== null) {
|
|
949
|
+
let onDisk;
|
|
950
|
+
try {
|
|
951
|
+
onDisk = fs.readFileSync(opts.check, "utf8");
|
|
952
|
+
} catch (e) {
|
|
953
|
+
io.error(`telemetry: cannot read ${opts.check} — ${e.message}`);
|
|
954
|
+
io.error("The golden is generated; run with --write to create it.");
|
|
955
|
+
return 2;
|
|
956
|
+
}
|
|
957
|
+
if (onDisk !== body) {
|
|
958
|
+
io.error(`telemetry: ${opts.check} is out of date against the payload this config renders`);
|
|
959
|
+
io.error("It is generated and byte-compared. Regenerate it with --write; do not edit it by hand.");
|
|
960
|
+
return 1;
|
|
961
|
+
}
|
|
962
|
+
io.log(`telemetry: ${opts.check} is byte-identical to the rendered payload (${emitted} metric(s))`);
|
|
963
|
+
return 0;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// ---- --export, the one mode that reaches the network
|
|
967
|
+
if (opts.export) {
|
|
968
|
+
// The gate, spelled ONCE and here. Non-zero and a sentence: for this tool silence is never an
|
|
969
|
+
// answer, because an opted-out emitter and a broken one are both silent.
|
|
970
|
+
if (config.enabled !== true) {
|
|
971
|
+
io.error(`telemetry: ${configPath} says enabled: false, so nothing was sent.`);
|
|
972
|
+
io.error("This is a verdict, not a failure: the config was read and it opts out. Emission is");
|
|
973
|
+
io.error("the maintainer's Gated act — the committed config is the standing consent, and this");
|
|
974
|
+
io.error("workspace has not given it. Use --render to see exactly what WOULD be sent.");
|
|
975
|
+
return 1;
|
|
976
|
+
}
|
|
977
|
+
// The consent must be COMMITTED, not merely written. See `consentIsCommitted`.
|
|
978
|
+
const committed = consentIsCommitted(configPath, opts.repoRoot);
|
|
979
|
+
if (!committed.ok) {
|
|
980
|
+
io.error(`telemetry: --export cannot run — ${committed.why}.`);
|
|
981
|
+
io.error("The ruling of 2026-08-28 is that the COMMITTED config is the standing consent, so a");
|
|
982
|
+
io.error("config that is not committed states nobody's decision. This is could-not-run, not a refusal.");
|
|
983
|
+
return 2;
|
|
984
|
+
}
|
|
985
|
+
const t = transportFromEnv(env);
|
|
986
|
+
if (!t.ok) {
|
|
987
|
+
io.error(`telemetry: --export cannot run — ${t.why}.`);
|
|
988
|
+
io.error("Transport comes from the OpenTelemetry standard environment; nothing is committed here.");
|
|
989
|
+
return 2;
|
|
990
|
+
}
|
|
991
|
+
let res;
|
|
992
|
+
try {
|
|
993
|
+
res = await post(t.url, t.headers, body);
|
|
994
|
+
} catch (e) {
|
|
995
|
+
io.error(`telemetry: the export failed — ${e.message}`);
|
|
996
|
+
io.error("Nothing is queued and nothing is retried: a queue that flushes itself later is a send");
|
|
997
|
+
io.error("nobody attended, which is what the Gated tier exists to prevent.");
|
|
998
|
+
return 2;
|
|
999
|
+
}
|
|
1000
|
+
if (!res.ok) {
|
|
1001
|
+
io.error(`telemetry: the collector answered ${res.status}${res.text ? ` — ${res.text.slice(0, 200)}` : ""}`);
|
|
1002
|
+
return 1;
|
|
1003
|
+
}
|
|
1004
|
+
// **Origin and path only, and the headers never.** A header list is where a bearer token lives —
|
|
1005
|
+
// and so is a URL, which may carry `user:pass@` or a query token. Withholding one and printing
|
|
1006
|
+
// the other was the same rule applied at one site and not its sibling.
|
|
1007
|
+
io.log(`telemetry: exported ${emitted} metric(s) to ${safeEndpoint(t.url)} — ${res.status}`);
|
|
1008
|
+
return 0;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// ---- --render, the default. No socket is opened on this path.
|
|
1012
|
+
io.log(body);
|
|
1013
|
+
io.log(`telemetry: rendered ${emitted} metric(s) over ${signals.length} signal(s); nothing was sent.`);
|
|
1014
|
+
io.log(` This workspace's config says enabled: ${config.enabled}. --export is the only mode that`);
|
|
1015
|
+
io.log(" reaches the network, and it refuses unless the committed config opts in.");
|
|
1016
|
+
io.log(" Every figure is a Gauge stamped with the instant it is ABOUT, not the instant it was sent;");
|
|
1017
|
+
io.log(" a stale snapshot therefore exports a stale timestamp, labelled as such rather than hidden.");
|
|
1018
|
+
return 0;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// The entry guard, in the ONE form `./rule-carriers.mjs` designates — copied rather than re-derived,
|
|
1022
|
+
// as `./drills.mjs` and `./fuzz-shell.mjs` both are. `file://${argv[1]}` is NOT that form:
|
|
1023
|
+
// `import.meta.url` percent-encodes and a working copy under a path with spaces then never matches,
|
|
1024
|
+
// so the tool exits 0 having never started. Four files here have shipped that defect.
|
|
1025
|
+
//
|
|
1026
|
+
// **It matters more in this file than in any of them.** An emitter that never starts sends nothing,
|
|
1027
|
+
// and an emitter that is correctly opted out also sends nothing. The two are indistinguishable from
|
|
1028
|
+
// outside, so the guard is the difference between a working gate and a tool that has silently stopped.
|
|
1029
|
+
function isMain() {
|
|
1030
|
+
const invoked = process.argv[1];
|
|
1031
|
+
if (!invoked) return false;
|
|
1032
|
+
if (import.meta.url === pathToFileURL(invoked).href) return true;
|
|
1033
|
+
try {
|
|
1034
|
+
return import.meta.url === pathToFileURL(fs.realpathSync(invoked)).href;
|
|
1035
|
+
} catch {
|
|
1036
|
+
return false;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// `process.exitCode` rather than `process.exit`, which `./control-chars.mjs` settled here: exiting
|
|
1041
|
+
// outright can truncate a pipe that has not drained, and a truncated line IS exit 0 with no output.
|
|
1042
|
+
if (isMain()) {
|
|
1043
|
+
run(process.argv.slice(2)).then((code) => {
|
|
1044
|
+
process.exitCode = code;
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
|