@sleepy_panda_srl/portulan 0.1.3 → 0.3.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 +7 -6
- package/cli/README.md +145 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1719 -1699
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +269 -1080
- package/cli/drills.mjs +92 -504
- 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 +882 -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 +6 -125
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +29 -265
- 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 +82 -505
- 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 +258 -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/README.md +33 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +5 -3
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +271 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/README.md +3 -3
- package/package.json +5 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/README.md +8 -8
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/packs/tools/local-llm/README.md +136 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1665 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +113 -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 +343 -27
- 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,131 +17,80 @@ 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: recipes reach neither the network nor local usage records
|
|
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 network mode in cli/ or a pack (named from `<category>/<name>/`); 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`" },
|
|
28
|
+
{
|
|
29
|
+
module: "tools/local-llm/runner/local-llm.mjs",
|
|
30
|
+
unless: "--verify-offline",
|
|
31
|
+
alsoUnless: ["eval", "--verify"],
|
|
32
|
+
what: "calls the model endpoint except offline rails and captured-record verification",
|
|
33
|
+
},
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** Local usage is machine-specific too; static mode must be explicit on each recipe invocation. */
|
|
37
|
+
export const LOCAL_RECORD_MODES = Object.freeze([
|
|
38
|
+
{ module: "cli/doctor.mjs", requiresStatic: "--static", what: "reads local usage records by default" },
|
|
39
|
+
{ module: "cli/portulan.mjs", subcommand: "doctor", requiresStatic: "--static", what: "dispatches to the local usage-record reader" },
|
|
40
|
+
{ module: "portulan", subcommand: "doctor", requiresStatic: "--static", what: "dispatches to the local usage-record reader" },
|
|
159
41
|
]);
|
|
160
42
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
*/
|
|
43
|
+
export const modeSpelling = (m) => m.requiresStatic ? `${m.module}${m.subcommand ? ` ${m.subcommand}` : ""} without an immediate ${m.requiresStatic}` : (m.flag ? `${m.module} ${m.flag}` : `${m.module} with anything but ${m.unless} alone${m.alsoUnless ? ` or ${m.alsoUnless.join(" ")} alone` : ""}`);
|
|
44
|
+
|
|
45
|
+
/** Whole-line comments only: a trailing `#` may sit inside a quote. */
|
|
172
46
|
export const stripShellComments = (source) =>
|
|
173
47
|
source
|
|
174
48
|
.split("\n")
|
|
175
49
|
.filter((line) => !/^\s*#/.test(line))
|
|
176
50
|
.join("\n");
|
|
177
51
|
|
|
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
52
|
const shellTokens = (body) => body.split(/\s+/).map((t) => t.replace(/^[("'`]+/, "").replace(/[)"'`;]+$/, "")).filter(Boolean);
|
|
190
53
|
|
|
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
54
|
const tokenNamesModule = (token, module) => token === module || token.endsWith(`/${module}`);
|
|
208
55
|
|
|
209
|
-
/** A flag token, in the two spellings a shell writes it: bare, or `--flag=value`. */
|
|
210
56
|
const tokenIsFlag = (token, flag) => token === flag || token.startsWith(`${flag}=`);
|
|
211
57
|
|
|
58
|
+
/** An `unless` row passes only where every invocation is the module and its one safe flag, alone on its command. */
|
|
59
|
+
const onlySafeInvocations = (source, m) =>
|
|
60
|
+
stripShellComments(source)
|
|
61
|
+
.replace(/\\\n/g, " ")
|
|
62
|
+
.split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
|
|
63
|
+
.every((segment) => {
|
|
64
|
+
const tokens = shellTokens(segment);
|
|
65
|
+
if (tokens[0] === "for" || ["[", "test"].includes(tokens[0]) || (tokens[0] === "if" && ["[", "test"].includes(tokens[1]))) return true;
|
|
66
|
+
return tokens.every((t, i) => !tokenNamesModule(t, m.module) || ((tokens.length === i + 2 && tokens[i + 1] === m.unless) || (m.alsoUnless && JSON.stringify(tokens.slice(i + 1)) === JSON.stringify(m.alsoUnless))));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const onlyStaticInvocations = (source, mode) => stripShellComments(source)
|
|
70
|
+
.replace(/\\\n/g, " ")
|
|
71
|
+
.split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
|
|
72
|
+
.every((segment) => {
|
|
73
|
+
const words = shellTokens(segment);
|
|
74
|
+
const first = words[0];
|
|
75
|
+
// A path in a file test or dependency list is data, not a CLI invocation.
|
|
76
|
+
if (["for", "printf", "echo", "[", "test"].includes(first) || (first === "if" && words.slice(1, 3).some((w) => ["[", "test"].includes(w)))) return true;
|
|
77
|
+
return words.every((word, i) => !tokenNamesModule(word, mode.module) || (mode.subcommand && words[i + 1] !== mode.subcommand) || words[i + (mode.subcommand ? 2 : 1)] === mode.requiresStatic);
|
|
78
|
+
});
|
|
79
|
+
|
|
212
80
|
export function auditRecipeSource(source) {
|
|
213
81
|
const tokens = shellTokens(stripShellComments(source));
|
|
214
|
-
|
|
82
|
+
const network = NETWORK_MODES.filter((m) =>
|
|
83
|
+
m.flag ? tokens.some((t) => tokenNamesModule(t, m.module)) && tokens.some((t) => tokenIsFlag(t, m.flag)) : !onlySafeInvocations(source, m),
|
|
84
|
+
);
|
|
85
|
+
const local = LOCAL_RECORD_MODES.filter((m) => !onlyStaticInvocations(source, m));
|
|
86
|
+
return [...network, ...local];
|
|
215
87
|
}
|
|
216
88
|
|
|
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
89
|
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
90
|
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
91
|
if (!isInside(path.resolve(repoRoot), workspace)) {
|
|
247
92
|
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
93
|
}
|
|
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
94
|
const roots = packRoots.map((r) => path.resolve(repoRoot, r));
|
|
256
95
|
let manifest;
|
|
257
96
|
try {
|
|
@@ -274,21 +113,14 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
|
274
113
|
const findings = [];
|
|
275
114
|
const read = [];
|
|
276
115
|
for (const r of recipes) {
|
|
277
|
-
|
|
278
|
-
//
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
.find((t) => t.endsWith(".sh"));
|
|
116
|
+
const words = String(r.run ?? "").split(/\s+/).filter(Boolean);
|
|
117
|
+
// A run line that is itself a `node` invocation is the whole shell text the recipe runs, so it is graded as read.
|
|
118
|
+
const direct = words[0] === "node" && !words.some((t) => t.endsWith(".sh"));
|
|
119
|
+
const script = direct ? words.find((t) => /\.[cm]?js$/.test(t)) : words.find((t) => t.endsWith(".sh"));
|
|
282
120
|
if (!script) {
|
|
283
121
|
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
122
|
}
|
|
285
123
|
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
124
|
if (!isInside(path.resolve(repoRoot), file)) {
|
|
293
125
|
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
126
|
}
|
|
@@ -299,7 +131,7 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
|
299
131
|
return { ok: false, why: `recipe ${JSON.stringify(r.id)} names ${script}, which could not be read — ${cause.message}` };
|
|
300
132
|
}
|
|
301
133
|
read.push(r.id);
|
|
302
|
-
for (const hit of auditRecipeSource(source)) findings.push({ recipe: r.id, script, ...hit });
|
|
134
|
+
for (const hit of auditRecipeSource(direct ? r.run : source)) findings.push({ recipe: r.id, script, ...hit });
|
|
303
135
|
}
|
|
304
136
|
return { ok: true, examined: read, findings };
|
|
305
137
|
}
|
|
@@ -311,13 +143,7 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
|
311
143
|
/** The config document's own version, so a future shape change is a refusal rather than a misread. */
|
|
312
144
|
export const CONFIG_SPEC = "1";
|
|
313
145
|
|
|
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
|
-
*/
|
|
146
|
+
/** The config's problems; empty means usable. */
|
|
321
147
|
export function validateConfig(config) {
|
|
322
148
|
const problems = [];
|
|
323
149
|
if (config === null || typeof config !== "object" || Array.isArray(config)) {
|
|
@@ -327,8 +153,6 @@ export function validateConfig(config) {
|
|
|
327
153
|
if (spec !== CONFIG_SPEC) {
|
|
328
154
|
problems.push(`portulan.telemetry must be ${JSON.stringify(CONFIG_SPEC)}, not ${JSON.stringify(spec ?? null)}`);
|
|
329
155
|
}
|
|
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
156
|
if (typeof config.enabled !== "boolean") {
|
|
333
157
|
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
158
|
}
|
|
@@ -347,8 +171,6 @@ export function validateConfig(config) {
|
|
|
347
171
|
}
|
|
348
172
|
}
|
|
349
173
|
}
|
|
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
174
|
for (const banned of ["headers", "endpoint", "token", "apiKey", "api_key"]) {
|
|
353
175
|
if (config[banned] !== undefined || config.exporter?.[banned] !== undefined) {
|
|
354
176
|
problems.push(
|
|
@@ -364,114 +186,37 @@ export function validateConfig(config) {
|
|
|
364
186
|
// The producer registry — the closed payload, as code
|
|
365
187
|
// ---------------------------------------------------------------------------------------------
|
|
366
188
|
|
|
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
|
-
*/
|
|
189
|
+
/** The payload's allow-list: a producer builds its rows from named figures and never spreads a snapshot into them. */
|
|
384
190
|
export const PRODUCERS = {
|
|
385
191
|
"review-loop": {
|
|
386
|
-
/** Where this producer's input is read from, relative to the repository root. */
|
|
387
192
|
input: "evals/review-loop/snapshot.json",
|
|
388
193
|
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
|
-
*/
|
|
194
|
+
/** OTLP's timeUnixNano is when a figure applies, so the capture stamp, which also keeps the golden byte-stable. */
|
|
397
195
|
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
196
|
validate: (snapshot) => validateSnapshot(snapshot),
|
|
411
197
|
rows(snapshot) {
|
|
412
198
|
const m = meter(snapshot);
|
|
413
199
|
return [
|
|
414
200
|
{ name: "portulan.review.pull_requests", unit: "{pull_request}", description: "Merged pull requests in the metered window.", value: m.pullRequests },
|
|
415
201
|
{ 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
|
|
202
|
+
{ name: "portulan.review.submissions_per_pull_request", unit: "1", description: "The figure the retired a-review-loop-needs-a-bound.md bounded the loop on.", value: m.submissionsPerPullRequest },
|
|
417
203
|
{ name: "portulan.review.submissions_no_inline", unit: "{submission}", description: "Submissions raising no inline comment.", value: m.noInline },
|
|
418
204
|
{ 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
205
|
{ 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
206
|
{ name: "portulan.review.pushes_per_submission", unit: "1", description: "The criterion's literal pushes-per-round figure, in submission units.", value: m.pushesPerSubmission },
|
|
421
207
|
];
|
|
422
208
|
},
|
|
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
209
|
attributes: (snapshot) => ({
|
|
433
210
|
"portulan.window.merged": snapshot.window?.merged ?? 0,
|
|
434
211
|
"portulan.window.captured": snapshot.captured ?? "",
|
|
435
212
|
"portulan.units": "submission",
|
|
436
213
|
}),
|
|
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
|
-
*/
|
|
214
|
+
/** The payload's one identifier, the repository's; nothing naming a person, such as a login, may join it. */
|
|
454
215
|
resource: (snapshot) => ({ "portulan.repository": snapshot.repository ?? "" }),
|
|
455
216
|
},
|
|
456
217
|
};
|
|
457
218
|
|
|
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
|
-
*/
|
|
219
|
+
/** Every attribute key a payload may carry; the suite fails a rendered key outside it. */
|
|
475
220
|
export const EMITTED_ATTRIBUTE_KEYS = Object.freeze([
|
|
476
221
|
"service.name",
|
|
477
222
|
"service.namespace",
|
|
@@ -484,38 +229,16 @@ export const EMITTED_ATTRIBUTE_KEYS = Object.freeze([
|
|
|
484
229
|
"portulan.units",
|
|
485
230
|
]);
|
|
486
231
|
|
|
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
232
|
export const REQUIRED_ATTRIBUTE_KEYS = Object.freeze(EMITTED_ATTRIBUTE_KEYS.filter((k) => k !== "service.namespace"));
|
|
496
233
|
|
|
497
234
|
// ---------------------------------------------------------------------------------------------
|
|
498
235
|
// OTLP/HTTP JSON — the wire shape, written by hand
|
|
499
236
|
// ---------------------------------------------------------------------------------------------
|
|
500
237
|
|
|
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
238
|
export function anyValue(v) {
|
|
507
239
|
if (typeof v === "string") return { stringValue: v };
|
|
508
240
|
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.
|
|
241
|
+
// 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
242
|
if (Number.isSafeInteger(v)) return { intValue: String(v) };
|
|
520
243
|
if (typeof v === "number" && Number.isFinite(v)) return { doubleValue: v };
|
|
521
244
|
throw new Error(`no OTLP encoding for ${JSON.stringify(v)}`);
|
|
@@ -523,21 +246,10 @@ export function anyValue(v) {
|
|
|
523
246
|
|
|
524
247
|
const kv = (attrs) => Object.entries(attrs).map(([key, value]) => ({ key, value: anyValue(value) }));
|
|
525
248
|
|
|
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
|
-
*/
|
|
249
|
+
/** Gauges, not Sums: a Sum owes a start time, and the snapshot records no window start. */
|
|
535
250
|
export function renderPayload({ config, signals, version = VERSION }) {
|
|
536
251
|
const resource = { "service.name": config.service.name, "telemetry.sdk.name": "portulan", "telemetry.sdk.language": "nodejs", "telemetry.sdk.version": version };
|
|
537
252
|
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
253
|
for (const s of signals) Object.assign(resource, s.resource ?? {});
|
|
542
254
|
|
|
543
255
|
const scopeMetrics = [];
|
|
@@ -545,9 +257,6 @@ export function renderPayload({ config, signals, version = VERSION }) {
|
|
|
545
257
|
const timeUnixNano = String(BigInt(Date.parse(s.capturedAt)) * 1000000n);
|
|
546
258
|
const attributes = kv(s.attributes);
|
|
547
259
|
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
260
|
.filter((r) => r.value !== null && Number.isFinite(r.value))
|
|
552
261
|
.map((r) => ({
|
|
553
262
|
name: r.name,
|
|
@@ -567,26 +276,7 @@ export const serialize = (payload) => `${JSON.stringify(payload, null, 2)}\n`;
|
|
|
567
276
|
// Transport — the ONE mode that reaches the network
|
|
568
277
|
// ---------------------------------------------------------------------------------------------
|
|
569
278
|
|
|
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
|
-
*/
|
|
279
|
+
/** A URL safe to print: origin and path, since userinfo and a query may carry credentials. */
|
|
590
280
|
export function safeEndpoint(href) {
|
|
591
281
|
try {
|
|
592
282
|
const u = new URL(href);
|
|
@@ -596,21 +286,12 @@ export function safeEndpoint(href) {
|
|
|
596
286
|
u.hash = "";
|
|
597
287
|
return u.href;
|
|
598
288
|
} 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
289
|
return "<withheld: not a parsable URL>";
|
|
602
290
|
}
|
|
603
291
|
}
|
|
604
292
|
|
|
293
|
+
/** Endpoint and headers only: the OTLP protocol, timeout, compression, certificate and client-key variables go unread. */
|
|
605
294
|
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
295
|
const specific = env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT;
|
|
615
296
|
const base = env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
616
297
|
const named = typeof specific === "string" && specific.length > 0 ? "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" : "OTEL_EXPORTER_OTLP_ENDPOINT";
|
|
@@ -622,14 +303,8 @@ export function transportFromEnv(env) {
|
|
|
622
303
|
try {
|
|
623
304
|
url = new URL(named === "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" ? raw : raw.endsWith("/") ? `${raw}v1/metrics` : `${raw}/v1/metrics`);
|
|
624
305
|
} 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
306
|
return { ok: false, why: `${named} is not a parsable URL — its value is withheld, since an endpoint may carry credentials` };
|
|
629
307
|
}
|
|
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
308
|
const headerSource = env.OTEL_EXPORTER_OTLP_METRICS_HEADERS ?? env.OTEL_EXPORTER_OTLP_HEADERS ?? "";
|
|
634
309
|
const headers = { "content-type": "application/json" };
|
|
635
310
|
for (const pair of headerSource.split(",")) {
|
|
@@ -639,76 +314,24 @@ export function transportFromEnv(env) {
|
|
|
639
314
|
return { ok: true, url: url.href, headers, from: named };
|
|
640
315
|
}
|
|
641
316
|
|
|
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
|
-
*/
|
|
317
|
+
/** The real send; the suite injects its own, since it runs inside a verify recipe, which may not reach the network. */
|
|
650
318
|
export async function postJson(url, headers, body) {
|
|
651
319
|
const res = await fetch(url, { method: "POST", headers, body });
|
|
652
320
|
return { status: res.status, ok: res.ok, text: await res.text().catch(() => "") };
|
|
653
321
|
}
|
|
654
322
|
|
|
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
|
-
*/
|
|
323
|
+
/** Refuses a config that is untracked, staged and never committed, or changed since HEAD. */
|
|
672
324
|
export function consentIsCommitted(configPath, repoRoot, spawn = spawnSync) {
|
|
673
325
|
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
326
|
const child = path.resolve(parent, configPath);
|
|
681
327
|
if (!isInside(parent, child)) {
|
|
682
328
|
return { ok: false, why: `${configPath} is outside the repository at ${repoRoot}, so nothing can establish that it is committed` };
|
|
683
329
|
}
|
|
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.
|
|
330
|
+
// Git pathspecs and HEAD:<path> want forward slashes, whatever separator path.relative gives.
|
|
695
331
|
const rel = path.relative(parent, child).split(path.sep).join("/");
|
|
696
332
|
const git = (args) => spawn("git", ["-C", parent, ...args], { encoding: "utf8" });
|
|
697
333
|
|
|
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.
|
|
334
|
+
// Asked first, since `git show HEAD:<path>` also exits 128 for a path staged but never committed.
|
|
712
335
|
const repo = git(["rev-parse", "--git-dir"]);
|
|
713
336
|
if (repo.error) return { ok: false, why: `git could not be run to establish whether the consent is committed — ${repo.error.message}` };
|
|
714
337
|
if (repo.status !== 0) {
|
|
@@ -724,18 +347,10 @@ export function consentIsCommitted(configPath, repoRoot, spawn = spawnSync) {
|
|
|
724
347
|
const head = git(["show", `HEAD:${rel}`]);
|
|
725
348
|
if (head.error) return { ok: false, why: `git could not read ${rel} at HEAD — ${head.error.message}` };
|
|
726
349
|
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
350
|
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
351
|
}
|
|
731
352
|
|
|
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.
|
|
353
|
+
// `git diff`, not a byte compare: under core.autocrlf a committed file checks out as CRLF over an LF blob.
|
|
739
354
|
const diff = git(["diff", "--quiet", "HEAD", "--", rel]);
|
|
740
355
|
if (diff.error) return { ok: false, why: `git could not compare ${rel} against HEAD — ${diff.error.message}` };
|
|
741
356
|
if (diff.status === 1) {
|
|
@@ -763,35 +378,16 @@ const USAGE = [
|
|
|
763
378
|
" first: OTEL_EXPORTER_OTLP_METRICS_ENDPOINT (used as given) else",
|
|
764
379
|
" OTEL_EXPORTER_OTLP_ENDPOINT (a base, +/v1/metrics); and",
|
|
765
380
|
" 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;",
|
|
381
|
+
" --audit-recipes assert that no recipe the workspace YIELDS can reach a network mode or read local usage records;",
|
|
767
382
|
" takes --workspace and --pack-root, and needs no --config",
|
|
768
383
|
"",
|
|
769
384
|
"exit 0 the thing asked for happened · 1 a verdict (golden drift, or export while opted out)",
|
|
770
385
|
" · 2 could not run (config or snapshot missing, unreadable, or malformed)",
|
|
771
386
|
].join("\n");
|
|
772
387
|
|
|
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
388
|
export const PATH_OPTIONS = Object.freeze(["config", "check", "write", "workspace"]);
|
|
788
389
|
|
|
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
|
-
*/
|
|
390
|
+
/** Resolves each path option against the repository root; an absolute one stays as given. */
|
|
795
391
|
export function pinPaths(opts) {
|
|
796
392
|
const root = path.resolve(opts.repoRoot);
|
|
797
393
|
const pinned = { ...opts, repoRoot: root, packRoots: opts.packRoots.map((r) => path.resolve(root, r)) };
|
|
@@ -841,11 +437,7 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
841
437
|
io.log(USAGE);
|
|
842
438
|
return 0;
|
|
843
439
|
}
|
|
844
|
-
// Pinned ONCE, here, before anything reads a path. See `PATH_OPTIONS`.
|
|
845
440
|
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
441
|
if (opts.audit) {
|
|
850
442
|
const res = auditRecipes({ workspaceDir: opts.workspace, repoRoot: opts.repoRoot, packRoots: opts.packRoots });
|
|
851
443
|
if (!res.ok) {
|
|
@@ -853,16 +445,16 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
853
445
|
return 2;
|
|
854
446
|
}
|
|
855
447
|
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
|
|
858
|
-
io.error("A verify recipe may not make a network call: spec/slots.md and .portulan/verify/README.md");
|
|
448
|
+
io.error(`telemetry: ${res.findings.length} verify recipe(s) can reach the network or local usage records:`);
|
|
449
|
+
for (const f of res.findings) io.error(` - ${f.recipe} (${f.script}) invokes ${modeSpelling(f)}, which ${f.what}`);
|
|
450
|
+
io.error("A verify recipe may not make a network call or read local usage records: spec/slots.md and .portulan/verify/README.md");
|
|
859
451
|
io.error("both prohibit it, because a rail that moves with the network goes red about the world");
|
|
860
452
|
io.error("rather than about the tree.");
|
|
861
453
|
return 1;
|
|
862
454
|
}
|
|
863
|
-
io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode`);
|
|
864
|
-
io.log(` Modes railed: ${NETWORK_MODES.map(
|
|
865
|
-
io.log(" A network mode with no row here is UNRAILED, and nothing can audit that a row was added.");
|
|
455
|
+
io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode or reads local usage records`);
|
|
456
|
+
io.log(` Modes railed: ${[...NETWORK_MODES, ...LOCAL_RECORD_MODES].map(modeSpelling).join(", ")}.`);
|
|
457
|
+
io.log(" A network or local-record mode with no row here is UNRAILED, and nothing can audit that a row was added.");
|
|
866
458
|
return 0;
|
|
867
459
|
}
|
|
868
460
|
|
|
@@ -877,9 +469,7 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
877
469
|
return 2;
|
|
878
470
|
}
|
|
879
471
|
|
|
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.
|
|
472
|
+
// ---- the config
|
|
883
473
|
const configPath = opts.config;
|
|
884
474
|
let config;
|
|
885
475
|
try {
|
|
@@ -965,8 +555,7 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
965
555
|
|
|
966
556
|
// ---- --export, the one mode that reaches the network
|
|
967
557
|
if (opts.export) {
|
|
968
|
-
//
|
|
969
|
-
// answer, because an opted-out emitter and a broken one are both silent.
|
|
558
|
+
// Never silent: an opted-out emitter and one that never started would look alike.
|
|
970
559
|
if (config.enabled !== true) {
|
|
971
560
|
io.error(`telemetry: ${configPath} says enabled: false, so nothing was sent.`);
|
|
972
561
|
io.error("This is a verdict, not a failure: the config was read and it opts out. Emission is");
|
|
@@ -974,7 +563,6 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
974
563
|
io.error("workspace has not given it. Use --render to see exactly what WOULD be sent.");
|
|
975
564
|
return 1;
|
|
976
565
|
}
|
|
977
|
-
// The consent must be COMMITTED, not merely written. See `consentIsCommitted`.
|
|
978
566
|
const committed = consentIsCommitted(configPath, opts.repoRoot);
|
|
979
567
|
if (!committed.ok) {
|
|
980
568
|
io.error(`telemetry: --export cannot run — ${committed.why}.`);
|
|
@@ -1001,14 +589,11 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
1001
589
|
io.error(`telemetry: the collector answered ${res.status}${res.text ? ` — ${res.text.slice(0, 200)}` : ""}`);
|
|
1002
590
|
return 1;
|
|
1003
591
|
}
|
|
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
592
|
io.log(`telemetry: exported ${emitted} metric(s) to ${safeEndpoint(t.url)} — ${res.status}`);
|
|
1008
593
|
return 0;
|
|
1009
594
|
}
|
|
1010
595
|
|
|
1011
|
-
// ---- --render, the default
|
|
596
|
+
// ---- --render, the default
|
|
1012
597
|
io.log(body);
|
|
1013
598
|
io.log(`telemetry: rendered ${emitted} metric(s) over ${signals.length} signal(s); nothing was sent.`);
|
|
1014
599
|
io.log(` This workspace's config says enabled: ${config.enabled}. --export is the only mode that`);
|
|
@@ -1018,14 +603,7 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
1018
603
|
return 0;
|
|
1019
604
|
}
|
|
1020
605
|
|
|
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.
|
|
606
|
+
// As file URLs, since `import.meta.url` percent-encodes a space; through realpath too, for an npm `bin` symlink.
|
|
1029
607
|
function isMain() {
|
|
1030
608
|
const invoked = process.argv[1];
|
|
1031
609
|
if (!invoked) return false;
|
|
@@ -1037,8 +615,7 @@ function isMain() {
|
|
|
1037
615
|
}
|
|
1038
616
|
}
|
|
1039
617
|
|
|
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.
|
|
618
|
+
// `process.exitCode` rather than `process.exit`, so a pipe that has not drained is not cut short.
|
|
1042
619
|
if (isMain()) {
|
|
1043
620
|
run(process.argv.slice(2)).then((code) => {
|
|
1044
621
|
process.exitCode = code;
|