@sleepy_panda_srl/portulan 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/cli/README.md +142 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1322 -1607
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +224 -1074
- package/cli/drills.mjs +68 -502
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +20 -305
- package/cli/gate.mjs +7 -199
- package/cli/goldens.mjs +26 -184
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +243 -566
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +778 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +9 -170
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +5 -74
- package/cli/pack-version.mjs +13 -255
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +5 -124
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +17 -258
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +10 -176
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +83 -514
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +27 -492
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +8 -86
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +183 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/consolidate/SKILL.md +4 -2
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +255 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/README.md +3 -3
- package/package.json +4 -2
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/spec/README.md +112 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +333 -21
- package/spec/workspace.schema.json +145 -4
package/cli/telemetry.mjs
CHANGED
|
@@ -1,120 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The OTel emitter
|
|
3
|
-
// can send anything anywhere on purpose.
|
|
2
|
+
// The OTel emitter: the review-loop figures as OTLP/HTTP JSON, sent only when a committed config opts in.
|
|
4
3
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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.
|
|
4
|
+
// node cli/telemetry.mjs --config <file> [--repo-root <dir>] [--render | --check <file> | --write <file> | --export]
|
|
5
|
+
// node cli/telemetry.mjs --audit-recipes [--workspace <dir>] [--repo-root <dir>] [--pack-root <dir>]...
|
|
9
6
|
//
|
|
10
|
-
//
|
|
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 …`.
|
|
7
|
+
// Exit 0 done · 1 a verdict (golden drift, export while opted out, a recipe reaching the network) · 2 could not run.
|
|
118
8
|
|
|
119
9
|
import fs from "node:fs";
|
|
120
10
|
import path from "node:path";
|
|
@@ -127,86 +17,27 @@ import { recipeSet, resolverFor } from "./recipe-set.mjs";
|
|
|
127
17
|
import { isInside } from "./inside.mjs";
|
|
128
18
|
|
|
129
19
|
// ---------------------------------------------------------------------------------------------
|
|
130
|
-
// The offline audit
|
|
20
|
+
// The offline audit: no verify recipe may reach the network
|
|
131
21
|
// ---------------------------------------------------------------------------------------------
|
|
132
22
|
|
|
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
|
-
*/
|
|
23
|
+
/** Every mode in cli/ that can reach the network; a new one is unrailed until it has a row here. */
|
|
149
24
|
export const NETWORK_MODES = Object.freeze([
|
|
150
25
|
{ module: "cli/review-meter.mjs", flag: "--fetch", what: "fetches review data from GitHub" },
|
|
151
26
|
{ 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
27
|
{ module: "cli/feedback.mjs", flag: "--approve", what: "files a GitHub issue with `gh issue create`" },
|
|
159
28
|
]);
|
|
160
29
|
|
|
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
|
-
*/
|
|
30
|
+
/** Whole-line comments only: a trailing `#` may sit inside a quote. */
|
|
172
31
|
export const stripShellComments = (source) =>
|
|
173
32
|
source
|
|
174
33
|
.split("\n")
|
|
175
34
|
.filter((line) => !/^\s*#/.test(line))
|
|
176
35
|
.join("\n");
|
|
177
36
|
|
|
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
37
|
const shellTokens = (body) => body.split(/\s+/).map((t) => t.replace(/^[("'`]+/, "").replace(/[)"'`;]+$/, "")).filter(Boolean);
|
|
190
38
|
|
|
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
39
|
const tokenNamesModule = (token, module) => token === module || token.endsWith(`/${module}`);
|
|
208
40
|
|
|
209
|
-
/** A flag token, in the two spellings a shell writes it: bare, or `--flag=value`. */
|
|
210
41
|
const tokenIsFlag = (token, flag) => token === flag || token.startsWith(`${flag}=`);
|
|
211
42
|
|
|
212
43
|
export function auditRecipeSource(source) {
|
|
@@ -214,44 +45,11 @@ export function auditRecipeSource(source) {
|
|
|
214
45
|
return NETWORK_MODES.filter((m) => tokens.some((t) => tokenNamesModule(t, m.module)) && tokens.some((t) => tokenIsFlag(t, m.flag)));
|
|
215
46
|
}
|
|
216
47
|
|
|
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
48
|
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
49
|
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
50
|
if (!isInside(path.resolve(repoRoot), workspace)) {
|
|
247
51
|
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
52
|
}
|
|
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
53
|
const roots = packRoots.map((r) => path.resolve(repoRoot, r));
|
|
256
54
|
let manifest;
|
|
257
55
|
try {
|
|
@@ -274,8 +72,6 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
|
274
72
|
const findings = [];
|
|
275
73
|
const read = [];
|
|
276
74
|
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
75
|
const script = String(r.run ?? "")
|
|
280
76
|
.split(/\s+/)
|
|
281
77
|
.find((t) => t.endsWith(".sh"));
|
|
@@ -283,12 +79,6 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
|
283
79
|
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
80
|
}
|
|
285
81
|
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
82
|
if (!isInside(path.resolve(repoRoot), file)) {
|
|
293
83
|
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
84
|
}
|
|
@@ -311,13 +101,7 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
|
311
101
|
/** The config document's own version, so a future shape change is a refusal rather than a misread. */
|
|
312
102
|
export const CONFIG_SPEC = "1";
|
|
313
103
|
|
|
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
|
-
*/
|
|
104
|
+
/** The config's problems; empty means usable. */
|
|
321
105
|
export function validateConfig(config) {
|
|
322
106
|
const problems = [];
|
|
323
107
|
if (config === null || typeof config !== "object" || Array.isArray(config)) {
|
|
@@ -327,8 +111,6 @@ export function validateConfig(config) {
|
|
|
327
111
|
if (spec !== CONFIG_SPEC) {
|
|
328
112
|
problems.push(`portulan.telemetry must be ${JSON.stringify(CONFIG_SPEC)}, not ${JSON.stringify(spec ?? null)}`);
|
|
329
113
|
}
|
|
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
114
|
if (typeof config.enabled !== "boolean") {
|
|
333
115
|
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
116
|
}
|
|
@@ -347,8 +129,6 @@ export function validateConfig(config) {
|
|
|
347
129
|
}
|
|
348
130
|
}
|
|
349
131
|
}
|
|
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
132
|
for (const banned of ["headers", "endpoint", "token", "apiKey", "api_key"]) {
|
|
353
133
|
if (config[banned] !== undefined || config.exporter?.[banned] !== undefined) {
|
|
354
134
|
problems.push(
|
|
@@ -364,49 +144,13 @@ export function validateConfig(config) {
|
|
|
364
144
|
// The producer registry — the closed payload, as code
|
|
365
145
|
// ---------------------------------------------------------------------------------------------
|
|
366
146
|
|
|
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
|
-
*/
|
|
147
|
+
/** The payload's allow-list: a producer builds its rows from named figures and never spreads a snapshot into them. */
|
|
384
148
|
export const PRODUCERS = {
|
|
385
149
|
"review-loop": {
|
|
386
|
-
/** Where this producer's input is read from, relative to the repository root. */
|
|
387
150
|
input: "evals/review-loop/snapshot.json",
|
|
388
151
|
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
|
-
*/
|
|
152
|
+
/** OTLP's timeUnixNano is when a figure applies, so the capture stamp, which also keeps the golden byte-stable. */
|
|
397
153
|
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
154
|
validate: (snapshot) => validateSnapshot(snapshot),
|
|
411
155
|
rows(snapshot) {
|
|
412
156
|
const m = meter(snapshot);
|
|
@@ -420,58 +164,17 @@ export const PRODUCERS = {
|
|
|
420
164
|
{ name: "portulan.review.pushes_per_submission", unit: "1", description: "The criterion's literal pushes-per-round figure, in submission units.", value: m.pushesPerSubmission },
|
|
421
165
|
];
|
|
422
166
|
},
|
|
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
167
|
attributes: (snapshot) => ({
|
|
433
168
|
"portulan.window.merged": snapshot.window?.merged ?? 0,
|
|
434
169
|
"portulan.window.captured": snapshot.captured ?? "",
|
|
435
170
|
"portulan.units": "submission",
|
|
436
171
|
}),
|
|
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
|
-
*/
|
|
172
|
+
/** The payload's one identifier, the repository's; nothing naming a person, such as a login, may join it. */
|
|
454
173
|
resource: (snapshot) => ({ "portulan.repository": snapshot.repository ?? "" }),
|
|
455
174
|
},
|
|
456
175
|
};
|
|
457
176
|
|
|
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
|
-
*/
|
|
177
|
+
/** Every attribute key a payload may carry; the suite fails a rendered key outside it. */
|
|
475
178
|
export const EMITTED_ATTRIBUTE_KEYS = Object.freeze([
|
|
476
179
|
"service.name",
|
|
477
180
|
"service.namespace",
|
|
@@ -484,38 +187,16 @@ export const EMITTED_ATTRIBUTE_KEYS = Object.freeze([
|
|
|
484
187
|
"portulan.units",
|
|
485
188
|
]);
|
|
486
189
|
|
|
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
190
|
export const REQUIRED_ATTRIBUTE_KEYS = Object.freeze(EMITTED_ATTRIBUTE_KEYS.filter((k) => k !== "service.namespace"));
|
|
496
191
|
|
|
497
192
|
// ---------------------------------------------------------------------------------------------
|
|
498
193
|
// OTLP/HTTP JSON — the wire shape, written by hand
|
|
499
194
|
// ---------------------------------------------------------------------------------------------
|
|
500
195
|
|
|
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
196
|
export function anyValue(v) {
|
|
507
197
|
if (typeof v === "string") return { stringValue: v };
|
|
508
198
|
if (typeof v === "boolean") return { boolValue: v };
|
|
509
|
-
//
|
|
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.
|
|
199
|
+
// Past 2^53 a number may not be the integer meant, so it goes out as a double rather than as a wrong int64.
|
|
519
200
|
if (Number.isSafeInteger(v)) return { intValue: String(v) };
|
|
520
201
|
if (typeof v === "number" && Number.isFinite(v)) return { doubleValue: v };
|
|
521
202
|
throw new Error(`no OTLP encoding for ${JSON.stringify(v)}`);
|
|
@@ -523,21 +204,10 @@ export function anyValue(v) {
|
|
|
523
204
|
|
|
524
205
|
const kv = (attrs) => Object.entries(attrs).map(([key, value]) => ({ key, value: anyValue(value) }));
|
|
525
206
|
|
|
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
|
-
*/
|
|
207
|
+
/** Gauges, not Sums: a Sum owes a start time, and the snapshot records no window start. */
|
|
535
208
|
export function renderPayload({ config, signals, version = VERSION }) {
|
|
536
209
|
const resource = { "service.name": config.service.name, "telemetry.sdk.name": "portulan", "telemetry.sdk.language": "nodejs", "telemetry.sdk.version": version };
|
|
537
210
|
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
211
|
for (const s of signals) Object.assign(resource, s.resource ?? {});
|
|
542
212
|
|
|
543
213
|
const scopeMetrics = [];
|
|
@@ -545,9 +215,6 @@ export function renderPayload({ config, signals, version = VERSION }) {
|
|
|
545
215
|
const timeUnixNano = String(BigInt(Date.parse(s.capturedAt)) * 1000000n);
|
|
546
216
|
const attributes = kv(s.attributes);
|
|
547
217
|
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
218
|
.filter((r) => r.value !== null && Number.isFinite(r.value))
|
|
552
219
|
.map((r) => ({
|
|
553
220
|
name: r.name,
|
|
@@ -567,26 +234,7 @@ export const serialize = (payload) => `${JSON.stringify(payload, null, 2)}\n`;
|
|
|
567
234
|
// Transport — the ONE mode that reaches the network
|
|
568
235
|
// ---------------------------------------------------------------------------------------------
|
|
569
236
|
|
|
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
|
-
*/
|
|
237
|
+
/** A URL safe to print: origin and path, since userinfo and a query may carry credentials. */
|
|
590
238
|
export function safeEndpoint(href) {
|
|
591
239
|
try {
|
|
592
240
|
const u = new URL(href);
|
|
@@ -596,21 +244,12 @@ export function safeEndpoint(href) {
|
|
|
596
244
|
u.hash = "";
|
|
597
245
|
return u.href;
|
|
598
246
|
} 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
247
|
return "<withheld: not a parsable URL>";
|
|
602
248
|
}
|
|
603
249
|
}
|
|
604
250
|
|
|
251
|
+
/** Endpoint and headers only: the OTLP protocol, timeout, compression, certificate and client-key variables go unread. */
|
|
605
252
|
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
253
|
const specific = env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT;
|
|
615
254
|
const base = env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
616
255
|
const named = typeof specific === "string" && specific.length > 0 ? "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" : "OTEL_EXPORTER_OTLP_ENDPOINT";
|
|
@@ -622,14 +261,8 @@ export function transportFromEnv(env) {
|
|
|
622
261
|
try {
|
|
623
262
|
url = new URL(named === "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" ? raw : raw.endsWith("/") ? `${raw}v1/metrics` : `${raw}/v1/metrics`);
|
|
624
263
|
} 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
264
|
return { ok: false, why: `${named} is not a parsable URL — its value is withheld, since an endpoint may carry credentials` };
|
|
629
265
|
}
|
|
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
266
|
const headerSource = env.OTEL_EXPORTER_OTLP_METRICS_HEADERS ?? env.OTEL_EXPORTER_OTLP_HEADERS ?? "";
|
|
634
267
|
const headers = { "content-type": "application/json" };
|
|
635
268
|
for (const pair of headerSource.split(",")) {
|
|
@@ -639,76 +272,24 @@ export function transportFromEnv(env) {
|
|
|
639
272
|
return { ok: true, url: url.href, headers, from: named };
|
|
640
273
|
}
|
|
641
274
|
|
|
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
|
-
*/
|
|
275
|
+
/** The real send; the suite injects its own, since it runs inside a verify recipe, which may not reach the network. */
|
|
650
276
|
export async function postJson(url, headers, body) {
|
|
651
277
|
const res = await fetch(url, { method: "POST", headers, body });
|
|
652
278
|
return { status: res.status, ok: res.ok, text: await res.text().catch(() => "") };
|
|
653
279
|
}
|
|
654
280
|
|
|
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
|
-
*/
|
|
281
|
+
/** Refuses a config that is untracked, staged and never committed, or changed since HEAD. */
|
|
672
282
|
export function consentIsCommitted(configPath, repoRoot, spawn = spawnSync) {
|
|
673
283
|
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
284
|
const child = path.resolve(parent, configPath);
|
|
681
285
|
if (!isInside(parent, child)) {
|
|
682
286
|
return { ok: false, why: `${configPath} is outside the repository at ${repoRoot}, so nothing can establish that it is committed` };
|
|
683
287
|
}
|
|
684
|
-
//
|
|
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.
|
|
288
|
+
// Git pathspecs and HEAD:<path> want forward slashes, whatever separator path.relative gives.
|
|
695
289
|
const rel = path.relative(parent, child).split(path.sep).join("/");
|
|
696
290
|
const git = (args) => spawn("git", ["-C", parent, ...args], { encoding: "utf8" });
|
|
697
291
|
|
|
698
|
-
//
|
|
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.
|
|
292
|
+
// Asked first, since `git show HEAD:<path>` also exits 128 for a path staged but never committed.
|
|
712
293
|
const repo = git(["rev-parse", "--git-dir"]);
|
|
713
294
|
if (repo.error) return { ok: false, why: `git could not be run to establish whether the consent is committed — ${repo.error.message}` };
|
|
714
295
|
if (repo.status !== 0) {
|
|
@@ -724,18 +305,10 @@ export function consentIsCommitted(configPath, repoRoot, spawn = spawnSync) {
|
|
|
724
305
|
const head = git(["show", `HEAD:${rel}`]);
|
|
725
306
|
if (head.error) return { ok: false, why: `git could not read ${rel} at HEAD — ${head.error.message}` };
|
|
726
307
|
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
308
|
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
309
|
}
|
|
731
310
|
|
|
732
|
-
//
|
|
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.
|
|
311
|
+
// `git diff`, not a byte compare: under core.autocrlf a committed file checks out as CRLF over an LF blob.
|
|
739
312
|
const diff = git(["diff", "--quiet", "HEAD", "--", rel]);
|
|
740
313
|
if (diff.error) return { ok: false, why: `git could not compare ${rel} against HEAD — ${diff.error.message}` };
|
|
741
314
|
if (diff.status === 1) {
|
|
@@ -770,28 +343,9 @@ const USAGE = [
|
|
|
770
343
|
" · 2 could not run (config or snapshot missing, unreadable, or malformed)",
|
|
771
344
|
].join("\n");
|
|
772
345
|
|
|
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
346
|
export const PATH_OPTIONS = Object.freeze(["config", "check", "write", "workspace"]);
|
|
788
347
|
|
|
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
|
-
*/
|
|
348
|
+
/** Resolves each path option against the repository root; an absolute one stays as given. */
|
|
795
349
|
export function pinPaths(opts) {
|
|
796
350
|
const root = path.resolve(opts.repoRoot);
|
|
797
351
|
const pinned = { ...opts, repoRoot: root, packRoots: opts.packRoots.map((r) => path.resolve(root, r)) };
|
|
@@ -841,11 +395,7 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
841
395
|
io.log(USAGE);
|
|
842
396
|
return 0;
|
|
843
397
|
}
|
|
844
|
-
// Pinned ONCE, here, before anything reads a path. See `PATH_OPTIONS`.
|
|
845
398
|
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
399
|
if (opts.audit) {
|
|
850
400
|
const res = auditRecipes({ workspaceDir: opts.workspace, repoRoot: opts.repoRoot, packRoots: opts.packRoots });
|
|
851
401
|
if (!res.ok) {
|
|
@@ -877,9 +427,7 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
877
427
|
return 2;
|
|
878
428
|
}
|
|
879
429
|
|
|
880
|
-
// ---- the config
|
|
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.
|
|
430
|
+
// ---- the config
|
|
883
431
|
const configPath = opts.config;
|
|
884
432
|
let config;
|
|
885
433
|
try {
|
|
@@ -965,8 +513,7 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
965
513
|
|
|
966
514
|
// ---- --export, the one mode that reaches the network
|
|
967
515
|
if (opts.export) {
|
|
968
|
-
//
|
|
969
|
-
// answer, because an opted-out emitter and a broken one are both silent.
|
|
516
|
+
// Never silent: an opted-out emitter and one that never started would look alike.
|
|
970
517
|
if (config.enabled !== true) {
|
|
971
518
|
io.error(`telemetry: ${configPath} says enabled: false, so nothing was sent.`);
|
|
972
519
|
io.error("This is a verdict, not a failure: the config was read and it opts out. Emission is");
|
|
@@ -974,7 +521,6 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
974
521
|
io.error("workspace has not given it. Use --render to see exactly what WOULD be sent.");
|
|
975
522
|
return 1;
|
|
976
523
|
}
|
|
977
|
-
// The consent must be COMMITTED, not merely written. See `consentIsCommitted`.
|
|
978
524
|
const committed = consentIsCommitted(configPath, opts.repoRoot);
|
|
979
525
|
if (!committed.ok) {
|
|
980
526
|
io.error(`telemetry: --export cannot run — ${committed.why}.`);
|
|
@@ -1001,14 +547,11 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
1001
547
|
io.error(`telemetry: the collector answered ${res.status}${res.text ? ` — ${res.text.slice(0, 200)}` : ""}`);
|
|
1002
548
|
return 1;
|
|
1003
549
|
}
|
|
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
550
|
io.log(`telemetry: exported ${emitted} metric(s) to ${safeEndpoint(t.url)} — ${res.status}`);
|
|
1008
551
|
return 0;
|
|
1009
552
|
}
|
|
1010
553
|
|
|
1011
|
-
// ---- --render, the default
|
|
554
|
+
// ---- --render, the default
|
|
1012
555
|
io.log(body);
|
|
1013
556
|
io.log(`telemetry: rendered ${emitted} metric(s) over ${signals.length} signal(s); nothing was sent.`);
|
|
1014
557
|
io.log(` This workspace's config says enabled: ${config.enabled}. --export is the only mode that`);
|
|
@@ -1018,14 +561,7 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
1018
561
|
return 0;
|
|
1019
562
|
}
|
|
1020
563
|
|
|
1021
|
-
//
|
|
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.
|
|
564
|
+
// As file URLs, since `import.meta.url` percent-encodes a space; through realpath too, for an npm `bin` symlink.
|
|
1029
565
|
function isMain() {
|
|
1030
566
|
const invoked = process.argv[1];
|
|
1031
567
|
if (!invoked) return false;
|
|
@@ -1037,8 +573,7 @@ function isMain() {
|
|
|
1037
573
|
}
|
|
1038
574
|
}
|
|
1039
575
|
|
|
1040
|
-
// `process.exitCode` rather than `process.exit`,
|
|
1041
|
-
// outright can truncate a pipe that has not drained, and a truncated line IS exit 0 with no output.
|
|
576
|
+
// `process.exitCode` rather than `process.exit`, so a pipe that has not drained is not cut short.
|
|
1042
577
|
if (isMain()) {
|
|
1043
578
|
run(process.argv.slice(2)).then((code) => {
|
|
1044
579
|
process.exitCode = code;
|