@codyswann/lisa 4.35.1 → 4.35.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/cli/apply.js +8 -8
  2. package/dist/cli/apply.js.map +1 -1
  3. package/dist/core/apply-receipt.d.ts +17 -12
  4. package/dist/core/apply-receipt.d.ts.map +1 -1
  5. package/dist/core/apply-receipt.js +3 -3
  6. package/dist/core/apply-receipt.js.map +1 -1
  7. package/dist/core/config.d.ts +10 -6
  8. package/dist/core/config.d.ts.map +1 -1
  9. package/dist/core/config.js.map +1 -1
  10. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  11. package/dist/core/lisa-owned-hash-ledger.js +2 -0
  12. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  13. package/dist/core/lisa.d.ts +34 -24
  14. package/dist/core/lisa.d.ts.map +1 -1
  15. package/dist/core/lisa.js +49 -41
  16. package/dist/core/lisa.js.map +1 -1
  17. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  18. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  19. package/dist/core/upstream-evidence-manifest.js +25 -2
  20. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  21. package/package.json +4 -4
  22. package/plugins/lisa/.claude-plugin/plugin.json +5 -1
  23. package/plugins/lisa/.codex-plugin/hooks.json +4 -0
  24. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  25. package/plugins/lisa/hooks/block-host-name-leak.agy.sh +61 -0
  26. package/plugins/lisa/hooks/block-host-name-leak.mjs +412 -0
  27. package/plugins/lisa/hooks/block-host-name-leak.sh +61 -0
  28. package/plugins/lisa-agy/hooks/block-host-name-leak.agy.sh +61 -0
  29. package/plugins/lisa-agy/hooks/block-host-name-leak.mjs +412 -0
  30. package/plugins/lisa-agy/hooks/block-host-name-leak.sh +61 -0
  31. package/plugins/lisa-agy/hooks.json +13 -0
  32. package/plugins/lisa-agy/plugin.json +1 -1
  33. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  36. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-copilot/.claude-plugin/plugin.json +5 -1
  39. package/plugins/lisa-copilot/hooks/block-host-name-leak.mjs +412 -0
  40. package/plugins/lisa-copilot/hooks/block-host-name-leak.sh +61 -0
  41. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-cursor/hooks/block-host-name-leak.mjs +412 -0
  43. package/plugins/lisa-cursor/hooks/block-host-name-leak.sh +61 -0
  44. package/plugins/lisa-cursor/hooks/hooks.json +4 -0
  45. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-expo-agy/plugin.json +1 -1
  48. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  53. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  57. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  58. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  59. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  62. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  63. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  67. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  68. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  71. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  72. package/plugins/lisa-rails-agy/plugin.json +1 -1
  73. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  76. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  77. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  78. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  81. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  82. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  83. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  85. package/plugins/src/base/.claude-plugin/plugin.json +4 -0
  86. package/plugins/src/base/hooks/block-host-name-leak.agy.sh +61 -0
  87. package/plugins/src/base/hooks/block-host-name-leak.mjs +412 -0
  88. package/plugins/src/base/hooks/block-host-name-leak.sh +61 -0
  89. package/scripts/downstream-name-digest.mjs +128 -0
  90. package/scripts/generate-agy-plugin-artifacts.mjs +12 -0
  91. package/scripts/reconcile-release-tags.mjs +331 -0
  92. package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +132 -39
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook for Bash: refuse an outbound tracker write that names a host
3
+ # project.
4
+ #
5
+ # The classification lives in block-host-name-leak.mjs beside this file, because
6
+ # the detector it must consult is compiled JavaScript (`dist/core/`) and calling
7
+ # it from Node is the only way to keep ONE denylist. A bash reimplementation of
8
+ # the matcher would be a second copy of the thing the guard exists to protect,
9
+ # and copies drift.
10
+ #
11
+ # This wrapper does three things and nothing else: probe its interpreters,
12
+ # unwrap the Claude hook envelope, and hand the command string to Node.
13
+ #
14
+ # FAILING OPEN, LOUDLY
15
+ #
16
+ # Claude Code treats exit 2 as a refusal and every OTHER non-zero exit as a
17
+ # non-blocking hook error — meaning the command runs. Under `set -euo pipefail`
18
+ # an absent jq exits 127, so an unguarded probe would permit exactly what this
19
+ # exists to stop. Degrading to "allow" is correct: a hook that cannot read its
20
+ # input cannot tell a tracker write from a directory listing, and failing closed
21
+ # would block every Bash call on a machine missing an interpreter. Announcing it
22
+ # is mandatory, because a guard that is silently absent reads exactly like a
23
+ # guard that is passing. Same reasoning as block-direct-issue-create.sh.
24
+ set -euo pipefail
25
+
26
+ input="$(cat)"
27
+
28
+ for required in jq node; do
29
+ if ! command -v "$required" >/dev/null 2>&1; then
30
+ printf 'block-host-name-leak: %s not found; host-identity enforcement is NOT active\n' \
31
+ "$required" >&2
32
+ exit 0
33
+ fi
34
+ done
35
+
36
+ tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)"
37
+ if [ "$tool_name" != "Bash" ]; then
38
+ exit 0
39
+ fi
40
+
41
+ command_str="$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
42
+ if [ -z "$command_str" ]; then
43
+ exit 0
44
+ fi
45
+
46
+ project_dir="${CLAUDE_PROJECT_DIR:-}"
47
+ if [ -z "$project_dir" ]; then
48
+ project_dir="$PWD"
49
+ fi
50
+
51
+ hook_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
52
+ classifier="$hook_dir/block-host-name-leak.mjs"
53
+ if [ ! -r "$classifier" ]; then
54
+ printf 'block-host-name-leak: classifier missing at %s; host-identity enforcement is NOT active\n' \
55
+ "$classifier" >&2
56
+ exit 0
57
+ fi
58
+
59
+ status=0
60
+ node "$classifier" "$command_str" "$project_dir" || status=$?
61
+ exit "$status"
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Turn a host name into the denylist entry that represents it, without ever
4
+ * writing the name down.
5
+ *
6
+ * WHY THIS EXISTS
7
+ *
8
+ * `HOST_NAME_ENTRIES` in `src/core/downstream-names.ts` holds truncated salted
9
+ * digests precisely so the list cannot be read out of a public repository. That
10
+ * is the right design and it has one cost: **adding an entry requires computing
11
+ * a digest, and there was no way to do it.** Three names known to be missing —
12
+ * a legal-entity org slug whose short product form is present, an organisation
13
+ * whose tracker-site slug differs from its repo-host org slug, and organisation
14
+ * email domains — have stayed missing for that reason rather than any other. A
15
+ * gap nobody can close in one step stays open.
16
+ *
17
+ * WHAT IT DELIBERATELY DOES NOT DO
18
+ *
19
+ * It does not write to `downstream-names.ts`, and it does not accept a file of
20
+ * names. Both would put plaintext identities on disk in a repository whose
21
+ * entire point here is that they are not on disk. It prints entries to stdout;
22
+ * a person pastes them in. The name reaches the terminal and nowhere else.
23
+ *
24
+ * Prefer shell history hygiene when running it: on most shells a leading space
25
+ * keeps the command out of the history file.
26
+ *
27
+ * USAGE
28
+ *
29
+ * node scripts/downstream-name-digest.mjs "Some Host" somehost.example
30
+ * printf 'one\ntwo\n' | node scripts/downstream-name-digest.mjs
31
+ *
32
+ * Each line of output is one array element for `HOST_NAME_ENTRIES`, already
33
+ * quoted and comma-terminated, sorted the way the existing list is.
34
+ * @module scripts/downstream-name-digest
35
+ */
36
+ import { createHash } from "node:crypto";
37
+
38
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
39
+
40
+ /**
41
+ * Domain separator, duplicated from `src/core/downstream-names.ts`.
42
+ *
43
+ * A second copy is the wrong shape in general, and it is the right one here:
44
+ * this script must run with no build step and no import of compiled output,
45
+ * because the person adding a name should not have to build the repository
46
+ * first. The duplication is guarded — `downstream-name-digest.test.ts` asserts
47
+ * this script's output equals `nameEntry()` for the same input, so a change to
48
+ * the salt or the truncation on either side fails immediately rather than
49
+ * silently producing entries that never match.
50
+ */
51
+ const DIGEST_SALT = "lisa/downstream-name/v1";
52
+ /** Hex characters kept from each digest — 40 bits. Mirrors the module. */
53
+ const DIGEST_HEX_LENGTH = 10;
54
+ /** Shortest name worth listing. Mirrors the module. */
55
+ const MIN_NAME_LENGTH = 5;
56
+
57
+ /**
58
+ * Reduce a name to the alphanumerics the matcher compares.
59
+ * @param raw - The name as a person writes it.
60
+ * @returns Lowercased alphanumerics only.
61
+ */
62
+ export function normalize(raw) {
63
+ return raw.toLowerCase().replaceAll(/[^a-z0-9]+/gu, "");
64
+ }
65
+
66
+ /**
67
+ * The `length:digest` entry for one name.
68
+ * @param raw - The name as a person writes it.
69
+ * @returns The entry, or undefined when the name is too short to list.
70
+ */
71
+ export function entryFor(raw) {
72
+ const normalized = normalize(raw);
73
+ if (normalized.length < MIN_NAME_LENGTH) return undefined;
74
+ const digest = createHash("sha256")
75
+ .update(DIGEST_SALT)
76
+ .update(normalized)
77
+ .digest("hex")
78
+ .slice(0, DIGEST_HEX_LENGTH);
79
+ return `${normalized.length}:${digest}`;
80
+ }
81
+
82
+ /**
83
+ * Read names from argv, or from stdin when argv carries none.
84
+ * @returns The raw names.
85
+ */
86
+ async function readNames() {
87
+ const fromArgs = process.argv.slice(2).filter(name => name !== "");
88
+ if (fromArgs.length > 0) return fromArgs;
89
+ const chunks = [];
90
+ for await (const chunk of process.stdin) chunks.push(chunk);
91
+ return chunks
92
+ .join("")
93
+ .split("\n")
94
+ .map(line => line.trim())
95
+ .filter(line => line !== "");
96
+ }
97
+
98
+ /** Print one entry per accepted name, and name the rejects on stderr. */
99
+ async function main() {
100
+ const names = await readNames();
101
+ if (names.length === 0) {
102
+ process.stderr.write(
103
+ "usage: node scripts/downstream-name-digest.mjs <name> [name...]\n"
104
+ );
105
+ process.exit(1);
106
+ }
107
+ const entries = names.map(name => ({ name, entry: entryFor(name) }));
108
+ for (const rejected of entries.filter(pair => pair.entry === undefined)) {
109
+ process.stderr.write(
110
+ `skipped: a name shorter than ${MIN_NAME_LENGTH} characters after normalization would collide with ordinary words (${rejected.name.length} given)\n`
111
+ );
112
+ }
113
+ const accepted = [
114
+ ...new Set(
115
+ entries
116
+ .filter(pair => pair.entry !== undefined)
117
+ .map(pair => `${pair.entry}`)
118
+ ),
119
+ ].sort();
120
+ for (const entry of accepted) process.stdout.write(` "${entry}",\n`);
121
+ process.stderr.write(
122
+ `\nPaste the ${accepted.length} line(s) above into HOST_NAME_ENTRIES in src/core/downstream-names.ts, keeping the array sorted.\nThe names themselves were not written anywhere.\n`
123
+ );
124
+ }
125
+
126
+ if (invokedAsScript(import.meta.url)) {
127
+ await main();
128
+ }
@@ -261,6 +261,18 @@ const AGY_PLUGIN_HOOKS = [
261
261
  agyScript: "block-direct-issue-create.agy.sh",
262
262
  supportScripts: ["block-direct-issue-create.sh"],
263
263
  },
264
+ {
265
+ // Same shape as the entry above, plus one support file the others do not
266
+ // need: the classifier is Node, because the denylist it consults is
267
+ // compiled JavaScript and a bash reimplementation would be a second copy of
268
+ // the thing the guard exists to protect.
269
+ sourceScript: "block-host-name-leak.sh",
270
+ hookName: "lisa-block-host-name-leak",
271
+ event: "PreToolUse",
272
+ matcher: "run_command",
273
+ agyScript: "block-host-name-leak.agy.sh",
274
+ supportScripts: ["block-host-name-leak.sh", "block-host-name-leak.mjs"],
275
+ },
264
276
  ];
265
277
 
266
278
  /**
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Which version tags correspond to a release that actually reached the registry (issue #3751).
4
+ *
5
+ * A tag is the artifact people trust when they ask "which version is live?",
6
+ * precisely because it looks authoritative. This repository pushes the tag
7
+ * BEFORE the step that makes a release a release for a consumer, so a tag can
8
+ * outlive the failure of the thing it asserts:
9
+ *
10
+ * deploy.yml
11
+ * release (release.yml)
12
+ * version computes the version
13
+ * release_signing git tag -s + git push refs/tags/... <- TAG PUSHED
14
+ * release_attestation
15
+ * github_release GitHub Release created
16
+ * publish_npm needs:[release] (publish-to-npm.yml)
17
+ * npm publish ... <- PUBLISH, AFTER
18
+ *
19
+ * `npm publish` runs in a separate downstream job, so the attestation, the
20
+ * GitHub Release and the whole publish job all happen after the tag exists.
21
+ *
22
+ * THE ORDERING IS DELIBERATE AND THIS SCRIPT DOES NOT CHANGE IT. Tagging early
23
+ * is what lets a re-run see "this version was already cut" and resume instead
24
+ * of duplicating. Moving the tag to the end would trade that away, and the
25
+ * alternatives are each worse: deleting a pushed tag is its own hazard once a
26
+ * consumer may have fetched it, and "tag early, mark incomplete" needs a place
27
+ * to record incompleteness that does not exist. So the tag stays where it is
28
+ * and this reconciles the residue instead.
29
+ *
30
+ * ## Three verdicts, because two would be a licence to do harm
31
+ *
32
+ * The verdicts are `published`, `missing` and `unprovable`, and the third is
33
+ * the load-bearing one. Retracting or flagging a GOOD release because the
34
+ * registry was briefly unreachable is the one direction this work can do harm,
35
+ * so anything that is not a definitive 404 on the exact-version endpoint is
36
+ * `unprovable` — a rate limit, a 5xx, a DNS failure, an auth error and a
37
+ * timeout are all "ask again later", never "this release did not happen".
38
+ *
39
+ * Note the asymmetry that makes this safe: `published` and `missing` are
40
+ * claims about the registry, while `unprovable` is a claim about THIS RUN.
41
+ *
42
+ * ## Bounded false positives (explicit acceptance criterion)
43
+ *
44
+ * A reconciliation that flags everything is as useless as one that flags
45
+ * nothing, and it looks far more diligent while doing it. The first draft of
46
+ * this scan reported 765 releases as untagged; every one was a false positive
47
+ * caused by reading only ONE of this repository's two tag conventions.
48
+ *
49
+ * - Two conventions are recognised: the current `v1.2.3` and the earlier
50
+ * `vv1.2.3`. Reading only the first misclassifies 765 correctly-tagged
51
+ * releases as untagged and hides 13 genuine orphans. This is the single
52
+ * highest-value line in the file and is pinned by a regression test.
53
+ * - Non-version refs (`backup/...`, `_fleet/...`, `pr-assets`) are not
54
+ * release tags and are never rows.
55
+ * - A prerelease or build-metadata suffix is not a plain release tag and is
56
+ * not reconciled here rather than being guessed at.
57
+ * - The tag list is the input; a version published with no tag at all is
58
+ * reported separately, because it is a different defect from a tag with no
59
+ * publication and conflating them produces a number nobody can act on.
60
+ *
61
+ * ## What this deliberately does NOT read
62
+ *
63
+ * It does not read workflow run history. That axis belongs to the release-time
64
+ * reconciliation (#3684), and it carries a trap worth recording here so the
65
+ * next reader does not walk into it: the default
66
+ * `GET /actions/runs/<id>/jobs` endpoint returns jobs for the LATEST ATTEMPT
67
+ * only. A run whose failed job was re-run, where the re-run was then cancelled,
68
+ * truthfully reports `{"total_count": 0, "jobs": []}` while holding its real
69
+ * jobs one attempt down, and `gh run view <id> --log` exits 0 printing nothing.
70
+ * A sweep built on those defaults would read a KNOWN failed release as having
71
+ * no evidence and return `unprovable` — the verdict that means "do not act".
72
+ * Use `attempts/<n>/jobs` or `?filter=all`.
73
+ *
74
+ * @module scripts/reconcile-release-tags
75
+ */
76
+
77
+ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
78
+
79
+ /**
80
+ * The verdicts, shared with the release-time reconciliation.
81
+ *
82
+ * One vocabulary for one concept: a second set of strings for the same three
83
+ * states is how two tickets about one incident start disagreeing in reports
84
+ * that are supposed to be compared.
85
+ */
86
+ export const VERDICT = Object.freeze({
87
+ PUBLISHED: "published",
88
+ MISSING: "missing",
89
+ UNPROVABLE: "unprovable",
90
+ });
91
+
92
+ /**
93
+ * Both tag conventions this repository has used, current first.
94
+ *
95
+ * `vv1.2.3` is not a typo — it is what the early release process produced, and
96
+ * 765 releases carry it. A pattern anchored to a single `v` silently reads
97
+ * every one of them as untagged.
98
+ */
99
+ const VERSION_TAG = /^vv?(\d+\.\d+\.\d+)$/;
100
+
101
+ /**
102
+ * The version a release tag names, or null when the ref is not a release tag.
103
+ * @param {string} tag A git ref name, without `refs/tags/`.
104
+ * @returns {string|null} The bare `x.y.z` version, or null.
105
+ */
106
+ export function parseVersionTag(tag) {
107
+ const matched = VERSION_TAG.exec(String(tag ?? "").trim());
108
+ return matched ? matched[1] : null;
109
+ }
110
+
111
+ /**
112
+ * What a single registry probe establishes.
113
+ *
114
+ * Only a definitive 404 on the EXACT-VERSION endpoint proves absence. Every
115
+ * other outcome — including a 200 that is not the version asked for, an auth
116
+ * failure, a rate limit and any transport error — establishes nothing about
117
+ * whether the release happened, and says so rather than guessing.
118
+ * @param {object} probe The probe result.
119
+ * @param {number|null} [probe.status] HTTP status, or null when none was received.
120
+ * @param {string|null} [probe.error] Transport-level failure, if any.
121
+ * @returns {string} One of the `VERDICT` values.
122
+ */
123
+ export function classifyProbe({ status = null, error = null } = {}) {
124
+ if (error) return VERDICT.UNPROVABLE;
125
+ if (status === 200) return VERDICT.PUBLISHED;
126
+ if (status === 404) return VERDICT.MISSING;
127
+ return VERDICT.UNPROVABLE;
128
+ }
129
+
130
+ /**
131
+ * Reconcile a tag list against a set of probes.
132
+ *
133
+ * Pure: the caller supplies both the refs and a probe function, so the
134
+ * classification is testable without a network and a fixture can reproduce a
135
+ * registry outage exactly.
136
+ * @param {object} options Inputs.
137
+ * @param {readonly string[]} options.tags Every ref name in the repository.
138
+ * @param {(version: string) => {status?: number|null, error?: string|null}} options.probe Registry probe.
139
+ * @returns {{rows: object[], ignored: string[]}} Rows per release tag, and refs that were not release tags.
140
+ */
141
+ export function reconcile({ tags, probe }) {
142
+ const rows = [];
143
+ const ignored = [];
144
+ for (const tag of tags) {
145
+ const version = parseVersionTag(tag);
146
+ if (version === null) {
147
+ ignored.push(tag);
148
+ continue;
149
+ }
150
+ rows.push({ tag, version, verdict: classifyProbe(probe(version)) });
151
+ }
152
+ return { rows, ignored };
153
+ }
154
+
155
+ /**
156
+ * The exact-version registry endpoint for one version.
157
+ *
158
+ * EXACT VERSION, never `dist-tags.latest`. `latest` lags a successful publish
159
+ * by minutes, so reconciling against it reports a release that shipped moments
160
+ * ago as absent — a false `missing` on the newest and most-scrutinised release.
161
+ * @param {string} pkg The package name.
162
+ * @param {string} version The exact version.
163
+ * @returns {string} The URL to probe.
164
+ */
165
+ export function registryUrl(pkg, version) {
166
+ return `https://registry.npmjs.org/${pkg.replace("/", "%2f")}/${version}`;
167
+ }
168
+
169
+ /**
170
+ * Probe the registry for one version, converting every failure into a shape
171
+ * `classifyProbe` can read rather than throwing.
172
+ * @param {string} pkg The package name.
173
+ * @param {string} version The exact version.
174
+ * @param {typeof fetch} [fetchImpl] Injected for tests.
175
+ * @returns {Promise<{status: number|null, error: string|null}>} The probe result.
176
+ */
177
+ export async function probeRegistry(pkg, version, fetchImpl = fetch) {
178
+ try {
179
+ const response = await fetchImpl(registryUrl(pkg, version), {
180
+ method: "GET",
181
+ });
182
+ if (response.status !== 200)
183
+ return { status: response.status, error: null };
184
+ // A 200 IS NOT ENOUGH — the body has to name the version that was asked
185
+ // for. This matches the release-time check (#3684) exactly: a 200 whose
186
+ // body names something else, or which does not parse, settles nothing and
187
+ // is `unprovable` rather than `published`. Two surfaces reporting the same
188
+ // word about one release have to mean the same thing by it, or comparing
189
+ // their reports is meaningless.
190
+ let body;
191
+ try {
192
+ body = await response.json();
193
+ } catch (error) {
194
+ return {
195
+ status: null,
196
+ error: `unparseable body: ${String(error?.message ?? error)}`,
197
+ };
198
+ }
199
+ if (body?.version !== version)
200
+ return {
201
+ status: null,
202
+ error: `registry served version ${String(body?.version)} for ${version}`,
203
+ };
204
+ return { status: 200, error: null };
205
+ } catch (error) {
206
+ // A transport failure is NOT evidence of absence. Returning it as an error
207
+ // is what makes `classifyProbe` answer `unprovable` instead of `missing`.
208
+ return { status: null, error: String(error?.message ?? error) };
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Probe a version, retrying ONLY while the answer is `unprovable`.
214
+ *
215
+ * A retry loop that also re-ran `missing` would be asking the registry to
216
+ * change its mind about a definite 404, and one that re-ran `published` would
217
+ * double the cost of the common case. Only "we could not look" is worth
218
+ * looking again at.
219
+ *
220
+ * This matters at sweep scale rather than for a single release. The first real
221
+ * run over 1,699 tags produced two `unprovable` rows, and one of them was
222
+ * `v2.325.4` — a version that IS published, whose probe simply failed in
223
+ * flight. Without a re-probe every sweep carries a couple of rows an operator
224
+ * has to chase by hand, which is how a report starts getting skimmed.
225
+ * @param {string} pkg The package name.
226
+ * @param {string} version The exact version.
227
+ * @param {object} [options] Retry controls.
228
+ * @param {number} [options.attempts] Total attempts, including the first.
229
+ * @param {() => Promise<void>} [options.pause] Delay between attempts.
230
+ * @param {typeof fetch} [options.fetchImpl] Injected for tests.
231
+ * @returns {Promise<{status: number|null, error: string|null}>} The last probe result.
232
+ */
233
+ export async function probeWithRetry(
234
+ pkg,
235
+ version,
236
+ { attempts = 2, pause = async () => {}, fetchImpl = fetch } = {}
237
+ ) {
238
+ let last = { status: null, error: "not attempted" };
239
+ for (let attempt = 0; attempt < Math.max(1, attempts); attempt += 1) {
240
+ if (attempt > 0) await pause();
241
+ last = await probeRegistry(pkg, version, fetchImpl);
242
+ if (classifyProbe(last) !== VERDICT.UNPROVABLE) return last;
243
+ }
244
+ return last;
245
+ }
246
+
247
+ /**
248
+ * The operator-facing report.
249
+ *
250
+ * SAYS WHAT THE VERDICTS MEAN, IN THE OUTPUT. A bare count of orphan tags
251
+ * reads as a count of failed releases, and those are different claims: this
252
+ * repository has 26 tags with no published version and exactly one of them is
253
+ * a proven failed release. Someone reading the report in a hurry must not be
254
+ * able to take the number for an incident count, so the distinction is printed
255
+ * rather than left in a doc comment nobody opens.
256
+ * @param {{rows: object[], ignored: string[]}} result As returned by `reconcile`.
257
+ * @returns {string} The report.
258
+ */
259
+ export function formatReport({ rows, ignored }) {
260
+ const of = verdict => rows.filter(row => row.verdict === verdict);
261
+ const missing = of(VERDICT.MISSING);
262
+ const unprovable = of(VERDICT.UNPROVABLE);
263
+ return [
264
+ "Release-tag reconciliation",
265
+ "",
266
+ ` release tags ${rows.length}`,
267
+ ` non-release refs ${ignored.length}`,
268
+ ` published ${of(VERDICT.PUBLISHED).length}`,
269
+ ` missing ${missing.length}`,
270
+ ` unprovable ${unprovable.length}`,
271
+ "",
272
+ "What the verdicts claim:",
273
+ " published — the exact version answered 200 on the registry.",
274
+ " missing — a claim about THE REGISTRY: the exact version answered 404.",
275
+ " It is NOT a claim that a release run failed. A tag can lack",
276
+ " a published version because the publish failed, because the",
277
+ " version was unpublished inside npm's 72-hour window, or",
278
+ " because it predates publishing from this repository.",
279
+ " Each one needs its release run read before it is called an",
280
+ " incident.",
281
+ " unprovable — a claim about THIS RUN: the registry could not be reached,",
282
+ " or answered something that settles nothing. Never treat it",
283
+ " as absence; run it again.",
284
+ "",
285
+ ...(missing.length
286
+ ? [
287
+ "Tags with no published version:",
288
+ ...missing.map(r => ` ${r.tag}`),
289
+ "",
290
+ ]
291
+ : []),
292
+ ...(unprovable.length
293
+ ? [
294
+ "Not established by this run (re-run before drawing any conclusion):",
295
+ ...unprovable.map(r => ` ${r.tag}`),
296
+ "",
297
+ ]
298
+ : []),
299
+ ].join("\n");
300
+ }
301
+
302
+ /**
303
+ * CLI entry point.
304
+ */
305
+ async function main() {
306
+ const pkg = process.env.LISA_RECONCILE_PACKAGE ?? "@codyswann/lisa";
307
+ const { execFileSync } = await import("node:child_process");
308
+ const tags = execFileSync("git", ["tag", "--list"], { encoding: "utf8" })
309
+ .split("\n")
310
+ .filter(Boolean);
311
+
312
+ const probes = new Map();
313
+ for (const tag of tags) {
314
+ const version = parseVersionTag(tag);
315
+ if (version === null || probes.has(version)) continue;
316
+ probes.set(version, await probeWithRetry(pkg, version));
317
+ }
318
+
319
+ const result = reconcile({
320
+ tags,
321
+ probe: version => probes.get(version) ?? {},
322
+ });
323
+ console.log(formatReport(result));
324
+ // Report-only: an orphan tag is a finding for a human to classify, not a
325
+ // build to fail. Exiting non-zero here would make every consumer of this
326
+ // script treat 26 historical tags as a red.
327
+ }
328
+
329
+ if (invokedAsScript(import.meta.url)) {
330
+ main();
331
+ }