@codyswann/lisa 4.35.2 → 4.35.4
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/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +2 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +25 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +5 -1
- package/plugins/lisa/.codex-plugin/hooks.json +4 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/block-host-name-leak.agy.sh +61 -0
- package/plugins/lisa/hooks/block-host-name-leak.mjs +412 -0
- package/plugins/lisa/hooks/block-host-name-leak.sh +61 -0
- package/plugins/lisa-agy/hooks/block-host-name-leak.agy.sh +61 -0
- package/plugins/lisa-agy/hooks/block-host-name-leak.mjs +412 -0
- package/plugins/lisa-agy/hooks/block-host-name-leak.sh +61 -0
- package/plugins/lisa-agy/hooks.json +13 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +5 -1
- package/plugins/lisa-copilot/hooks/block-host-name-leak.mjs +412 -0
- package/plugins/lisa-copilot/hooks/block-host-name-leak.sh +61 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/block-host-name-leak.mjs +412 -0
- package/plugins/lisa-cursor/hooks/block-host-name-leak.sh +61 -0
- package/plugins/lisa-cursor/hooks/hooks.json +4 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/.claude-plugin/plugin.json +4 -0
- package/plugins/src/base/hooks/block-host-name-leak.agy.sh +61 -0
- package/plugins/src/base/hooks/block-host-name-leak.mjs +412 -0
- package/plugins/src/base/hooks/block-host-name-leak.sh +61 -0
- package/scripts/downstream-name-digest.mjs +128 -0
- package/scripts/generate-agy-plugin-artifacts.mjs +12 -0
- package/scripts/reconcile-release-tags.mjs +331 -0
- 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
|
+
}
|