@starci/hfs 4.7.41 → 4.7.43
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/CHANGELOG.md +10 -0
- package/lint/chunk.mjs +46 -0
- package/lint/run.mjs +14 -2
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/scripts/guards/release-definition.mjs +204 -204
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +36 -7
- package/runtime/scripts/hfs/rules/unused-symbol-flow.mjs +39 -5
- package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
- package/runtime/scripts/lib/bypass-markers.mjs +14 -2
- package/runtime/scripts/lib/release-version.mjs +134 -134
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.7.43 - 2026-10-26
|
|
4
|
+
- Fixed: the ESLint launch of `starci app lint` is chunked by command-line size and a spawn error is reported as an eslint error.
|
|
5
|
+
- Changed: R280 (unused symbols) reads one assignment-target index per run instead of scanning the tree per class member.
|
|
6
|
+
- Changed: the bundled canon pins follow the lint family bumps (canon-be 3.4.50, canon-fe 9.1.5, Grammar 0.12.9).
|
|
7
|
+
|
|
8
|
+
## 4.7.42 - 2026-10-26
|
|
9
|
+
- Fixed: `HFS_BYPASS_MARKER` reads a skip/only index only (`describe["skip"]`), not a marker word inside an array literal.
|
|
10
|
+
- Fixed: the BE SQL owner analysis bounds a FROM-less and an aggregate-only SELECT as one row.
|
|
11
|
+
- Changed: the bundled canon pins follow the lint family bumps (canon-be 3.4.49, canon-fe 9.1.4, Grammar 0.12.8).
|
|
12
|
+
|
|
3
13
|
## 4.7.41 - 2026-10-26
|
|
4
14
|
- Changed: the bundled canon pins follow the lint family bumps (canon-be 3.4.48, canon-fe 9.1.3, Grammar 0.12.7). No behavior change.
|
|
5
15
|
|
package/lint/chunk.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// chunk.mjs - split a long argument list under the command-line limit (Windows allows 32,767 characters) and run the parts with bounded concurrency.
|
|
2
|
+
|
|
3
|
+
/** The command-line characters one argument costs: its length, a separating space and a pair of quotes. */
|
|
4
|
+
const cost = (arg) => String(arg).length + 3;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Split `items` into consecutive chunks whose summed command-line cost stays within `budget` characters. Order is kept. An item that alone
|
|
8
|
+
* exceeds the budget gets a chunk of its own (the spawn then fails visibly instead of being dropped).
|
|
9
|
+
*/
|
|
10
|
+
export function chunkByChars(items, budget = 24_000) {
|
|
11
|
+
const chunks = [];
|
|
12
|
+
let current = [], used = 0;
|
|
13
|
+
for (const item of items) {
|
|
14
|
+
const size = cost(item);
|
|
15
|
+
if (current.length && used + size > budget) { chunks.push(current); current = []; used = 0; }
|
|
16
|
+
current.push(item);
|
|
17
|
+
used += size;
|
|
18
|
+
}
|
|
19
|
+
if (current.length) chunks.push(current);
|
|
20
|
+
return chunks;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Run `task(chunk)` for every chunk with at most `limit` in flight. Results keep the chunk order; a thrown task becomes `{ error }`. */
|
|
24
|
+
export async function mapBounded(chunks, task, limit = 3) {
|
|
25
|
+
const out = new Array(chunks.length);
|
|
26
|
+
let next = 0;
|
|
27
|
+
const worker = async () => {
|
|
28
|
+
while (next < chunks.length) {
|
|
29
|
+
const at = next++;
|
|
30
|
+
try { out[at] = await task(chunks[at], at); } catch (error) { out[at] = { error: `could not start a lint child: ${String(error?.message ?? error)}` }; }
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
await Promise.all(Array.from({ length: Math.min(limit, chunks.length) }, worker));
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Merge the `{ results, error }` records of the chunks, in chunk order: every result kept, every distinct error reported. */
|
|
38
|
+
export function mergeRuns(runs) {
|
|
39
|
+
const results = [], errors = [];
|
|
40
|
+
let any = false;
|
|
41
|
+
for (const run of runs) {
|
|
42
|
+
if (run?.results) { results.push(...run.results); any = true; }
|
|
43
|
+
if (run?.error && !errors.includes(run.error)) errors.push(run.error);
|
|
44
|
+
}
|
|
45
|
+
return { ...(any ? { results } : {}), ...(errors.length ? { error: errors.join(' ; ') } : {}) };
|
|
46
|
+
}
|
package/lint/run.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { spawn } from 'node:child_process';
|
|
|
22
22
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
23
23
|
import { linterReport, mergeReports, sonarReport, sourceRootsOf } from '../report/sonar.mjs';
|
|
24
24
|
import { SIDES, appRelativeMessages, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
|
|
25
|
+
import { chunkByChars, mapBounded, mergeRuns } from './chunk.mjs';
|
|
25
26
|
import { STYLE_GLOB } from '../sync/index.mjs';
|
|
26
27
|
import { byCodeUnit } from '../report/order.mjs';
|
|
27
28
|
import { braceVariants, globExpression } from '../runtime/scripts/lib/glob.mjs';
|
|
@@ -88,7 +89,9 @@ const linterBoundArgs = () => ['--require', fileURLToPath(new URL('./bound-sys.c
|
|
|
88
89
|
/** The child `node` ran with `args` from `cwd`: its exit status, signal, spawn error and the text of both streams, once it closes. */
|
|
89
90
|
function runNode(args, options) {
|
|
90
91
|
return new Promise((resolve) => {
|
|
91
|
-
|
|
92
|
+
let child;
|
|
93
|
+
try { child = spawn(process.execPath, args, { ...options, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); }
|
|
94
|
+
catch (error) { resolve({ status: 2, signal: null, error, stdout: '', stderr: String(error?.message ?? error) }); return; }
|
|
92
95
|
const out = [];
|
|
93
96
|
const err = [];
|
|
94
97
|
let failure;
|
|
@@ -245,6 +248,11 @@ function checkedEslintResults({ results, expected, cwd, scope, changed }) {
|
|
|
245
248
|
return { results: valid, errors };
|
|
246
249
|
}
|
|
247
250
|
|
|
251
|
+
const ESLINT_ARGV_BUDGET = 24_000;
|
|
252
|
+
const ESLINT_CONCURRENCY = 3;
|
|
253
|
+
/** A launch that threw instead of returning a record is a tool error of the run (exit 2), not a crash of the CLI. */
|
|
254
|
+
const spawnFailure = (error) => ({ error: `could not start eslint: ${String(error?.message ?? error)}` });
|
|
255
|
+
|
|
248
256
|
/** Starts the ESLint child of every side that has work and returns one record per side: skipped, a scope error, or the running child. */
|
|
249
257
|
async function launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe, run = runLinter }) {
|
|
250
258
|
const launches = [];
|
|
@@ -256,7 +264,11 @@ async function launchEslint({ app, repoRoot, opts, changed, existing, workspace,
|
|
|
256
264
|
let expected;
|
|
257
265
|
try { expected = changed ? sources : await sourceFiles({ repoRoot, cwd, side, scope: onFe }); }
|
|
258
266
|
catch (error) { launches.push({ side, scopeError: `eslint source scope is unavailable in ${side}/: ${String(error?.message ?? error)}` }); continue; }
|
|
259
|
-
|
|
267
|
+
const head = ['--format', 'json', ...(opts.fix ? ['--fix'] : [])];
|
|
268
|
+
const launch = (files) => Promise.resolve().then(() => run({ cwd, pkg: 'eslint', bin: 'eslint', args: [...head, ...files], bound: repoRoot }));
|
|
269
|
+
// A diff of hundreds of files exceeds the Windows command line: the sources go in chunks and the reports are merged.
|
|
270
|
+
const chunks = changed ? chunkByChars(sources, ESLINT_ARGV_BUDGET) : [[onFe ?? '.']];
|
|
271
|
+
launches.push({ side, cwd, expected, run: chunks.length === 1 ? launch(chunks[0]).catch(spawnFailure) : mapBounded(chunks, launch, ESLINT_CONCURRENCY).then(mergeRuns) });
|
|
260
272
|
}
|
|
261
273
|
return launches;
|
|
262
274
|
}
|
package/package.json
CHANGED
|
@@ -23,35 +23,35 @@ pins:
|
|
|
23
23
|
source: packages/heroicons/package.json
|
|
24
24
|
why: 'StarCi custom Heroicons-compatible cuts; eslint-grammar-starci (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
|
|
25
25
|
'@starci/grammar':
|
|
26
|
-
version: 0.12.
|
|
26
|
+
version: 0.12.9
|
|
27
27
|
group: starci
|
|
28
28
|
install: registry
|
|
29
29
|
side: fe
|
|
30
30
|
source: packages/grammar/package.json
|
|
31
31
|
why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
|
|
32
32
|
'@starci/eslint-canon-be':
|
|
33
|
-
version: 3.4.
|
|
33
|
+
version: 3.4.50
|
|
34
34
|
group: starci
|
|
35
35
|
install: registry
|
|
36
36
|
side: be
|
|
37
37
|
source: packages/eslint/be/package.json
|
|
38
38
|
why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
|
|
39
39
|
'@starci/eslint-canon-fe':
|
|
40
|
-
version: 9.1.
|
|
40
|
+
version: 9.1.5
|
|
41
41
|
group: starci
|
|
42
42
|
install: registry
|
|
43
43
|
side: fe
|
|
44
44
|
source: packages/eslint/fe/package.json
|
|
45
45
|
why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
|
|
46
46
|
'@starci/eslint-grammar-starci':
|
|
47
|
-
version: 0.12.
|
|
47
|
+
version: 0.12.9
|
|
48
48
|
group: starci
|
|
49
49
|
install: registry
|
|
50
50
|
side: fe
|
|
51
51
|
source: packages/eslint/grammar-starci/package.json
|
|
52
52
|
why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
|
|
53
53
|
'@starci/eslint-grammar-offset-pop':
|
|
54
|
-
version: 0.12.
|
|
54
|
+
version: 0.12.9
|
|
55
55
|
group: starci
|
|
56
56
|
install: registry
|
|
57
57
|
side: fe
|
|
@@ -91,14 +91,14 @@ pins:
|
|
|
91
91
|
source: packages/test-world/package.json
|
|
92
92
|
why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
|
|
93
93
|
'@starci/cli':
|
|
94
|
-
version: 1.7.
|
|
94
|
+
version: 1.7.43
|
|
95
95
|
group: starci
|
|
96
96
|
install: registry
|
|
97
97
|
side: both
|
|
98
98
|
source: packages/cli/package.json
|
|
99
99
|
why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
|
|
100
100
|
'@starci/hfs':
|
|
101
|
-
version: 4.7.
|
|
101
|
+
version: 4.7.43
|
|
102
102
|
group: starci
|
|
103
103
|
install: registry
|
|
104
104
|
side: both
|
|
@@ -1,204 +1,204 @@
|
|
|
1
|
-
// release-definition.mjs - what makes a commit of the runtime repository a RELEASE commit, the one definition shared by the
|
|
2
|
-
// pre-push hook (scripts/guards/release-push-gate.mjs) and the release cut (scripts/supervisor/release-cut.mjs). The remote
|
|
3
|
-
// main of the runtime moves exactly when a release is cut, so a push of main or of a v* tag is judged against this list:
|
|
4
|
-
// RELEASE_VERSION_UNCHANGED package.json `version` at the pushed commit differs from the version at the remote main's head
|
|
5
|
-
// RELEASE_VERSION_NOT_NEXT that version is the next release after the highest v* tag (scripts/lib/release-version.mjs, owner ruling release-waves):
|
|
6
|
-
// the next wave alpha.N.M+1 of its line or the next line alpha.N+1, never the legacy -fix form
|
|
7
|
-
// RELEASE_TAG_VERSION the release tag is v<version> of the pushed commit
|
|
8
|
-
// RELEASE_CHANGELOG_SECTION CHANGELOG.md holds `## [<version>] - <date>` (a dated heading) with no unfinished marker (R222)
|
|
9
|
-
// RELEASE_TAG_MISSING an ANNOTATED tag v<version> points at the pushed commit
|
|
10
|
-
// RELEASE_RECEIPT_MISSING the L4 record of exactly this commit (scripts/guards/release-record.mjs, written by the cut) names this tag
|
|
11
|
-
// RELEASE_RECEIPT_INCOMPLETE that record holds a green row for each of RECEIPT_STEPS (the package clean-test, the scaffold e2e and the checks) (RAN on this commit or reused from another with the identical input digest, the record's `reusedFrom` names it; owner ruling speed-flow, 2026-10-25)
|
|
12
|
-
// RELEASE_SUITE_MODE the record says `suite: ci` (the Linux parity delegated to CI) while the checked-out config says `local`, or it lists no delegated linux-parity: a record never claims the mode by
|
|
13
|
-
// itself (scripts/guards/release-suite-mode.mjs); both modes ask for the same root rows, the runtime has no spec suite to delegate (owner ruling no-runtime-specs)
|
|
14
|
-
// Each finding names what is missing and the command that produces it. Pure over the git reads; no bypass.
|
|
15
|
-
import { catFile } from '../api/git/cat-file.mjs';
|
|
16
|
-
import { revParseQuery } from '../api/git/rev-parse-query.mjs';
|
|
17
|
-
import { show } from '../api/git/show.mjs';
|
|
18
|
-
import { tag as gitTag } from '../api/git/tag.mjs';
|
|
19
|
-
import { mergeBaseQuery } from '../api/git/merge-base-query.mjs';
|
|
20
|
-
import { pathKey, realPath } from '../lib/path-key.mjs';
|
|
21
|
-
import { changelogSection, releaseNotesFindings } from '../hfs/runtime-rules/release-notes.mjs';
|
|
22
|
-
import { nextReleases, releaseVersionRefusal, taggedVersions } from '../lib/release-version.mjs';
|
|
23
|
-
import { readL4Record } from './release-record.mjs';
|
|
24
|
-
import { suiteModeOf } from './release-suite-mode.mjs';
|
|
25
|
-
|
|
26
|
-
/** The rows the L4 record must hold green, run on the commit or reused from an identical-digest row of another, in either suite mode: the package clean-test, the scaffold e2e and the checks (the runtime's real tests). */
|
|
27
|
-
export const RECEIPT_STEPS = Object.freeze(['npm run test:packages', 'npm run test:e2e', 'npm run check']);
|
|
28
|
-
const CUT_COMMAND = (tag) => `starci release cut --tag ${tag}`;
|
|
29
|
-
const REGEX_SPECIALS = /[.*+?^$()|[\]{}\\]/g;
|
|
30
|
-
const HEADING_HEAD = String.raw`^## \[`;
|
|
31
|
-
const DATED_HEADING_TAIL = String.raw`\] — \d{4}-\d{2}-\d{2}\s*$`;
|
|
32
|
-
const DATED_HEADING = (version) => new RegExp([HEADING_HEAD, version.replaceAll(REGEX_SPECIALS, String.raw`\$&`), DATED_HEADING_TAIL].join(''), 'mu');
|
|
33
|
-
|
|
34
|
-
const finding = (code, missing, fix) => ({ code, missing, fix });
|
|
35
|
-
const gitText = (verb, args, cwd) => {
|
|
36
|
-
const r = verb(args, { cwd, timeout: 60_000 });
|
|
37
|
-
return r.status === 0 ? String(r.stdout ?? '') : null;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/** The package.json version of `commit`, or null when the commit or its manifest cannot be read. */
|
|
41
|
-
export function versionAt(cwd, commit) {
|
|
42
|
-
const text = gitText(show, [`${commit}:package.json`], cwd);
|
|
43
|
-
try { return text === null ? null : JSON.parse(text).version ?? null; } catch { return null; }
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const releaseUnknown = (why) => ({ ok: false, status: 'unknown', why });
|
|
47
|
-
const ineligibleRelease = (explicit, why) => explicit ? releaseUnknown(why) : { ok: true, candidate: null };
|
|
48
|
-
|
|
49
|
-
/** A runtime Git root's nearest annotated, manifest-bound release; enclosing application history is inapplicable. */
|
|
50
|
-
export function releaseTagOf({ repo, tag = null, excludeHead = false }) {
|
|
51
|
-
const history = runtimeGitHistory(repo);
|
|
52
|
-
if (!history.ok) return history;
|
|
53
|
-
const listed = tag === null ? gitText(gitTag, ['--merged', 'HEAD', '--list', 'v*'], repo) : tag;
|
|
54
|
-
if (listed === null) return releaseUnknown('release tags are unreadable');
|
|
55
|
-
const candidates = [];
|
|
56
|
-
for (const named of listed.split(/\r?\n/).filter(Boolean)) {
|
|
57
|
-
const result = releaseCandidate({ repo, named, current: history.head, explicit: tag !== null, excludeHead });
|
|
58
|
-
if (!result.ok) return result;
|
|
59
|
-
if (result.candidate) candidates.push(result.candidate);
|
|
60
|
-
}
|
|
61
|
-
return nearestRelease(repo, candidates);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Runtime history exists only at its own Git top-level with a readable HEAD and full ancestry. */
|
|
65
|
-
function runtimeGitHistory(repo) {
|
|
66
|
-
const top = revParseQuery(['--show-toplevel'], { cwd: repo });
|
|
67
|
-
if (top.status !== 0) {
|
|
68
|
-
if (/not a git repository/i.test(String(top.stderr ?? ''))) return { ok: false, status: 'no-repository', why: 'release history requires a Git repository' };
|
|
69
|
-
return releaseUnknown('release repository identity is unreadable');
|
|
70
|
-
}
|
|
71
|
-
const gitRoot = String(top.stdout ?? '').trim();
|
|
72
|
-
if (!gitRoot) return releaseUnknown('release repository root is unreadable');
|
|
73
|
-
if (pathKey(realPath(gitRoot)) !== pathKey(realPath(repo))) return { ok: false, status: 'no-runtime-repository', why: 'the runtime copy is inside an enclosing Git repository; its history belongs to that repository' };
|
|
74
|
-
const head = gitText(revParseQuery, ['HEAD'], repo)?.trim();
|
|
75
|
-
if (!head) return releaseUnknown('release history HEAD is unreadable');
|
|
76
|
-
if (gitText(revParseQuery, ['--is-shallow-repository'], repo)?.trim() !== 'false') return releaseUnknown('release history is shallow or unreadable; fetch complete release history');
|
|
77
|
-
return { ok: true, head };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** A candidate binds annotation, peeled commit, manifest spelling and ancestry; default discovery omits ineligible tags. */
|
|
81
|
-
function releaseCandidate({ repo, named, current, explicit, excludeHead }) {
|
|
82
|
-
const kind = gitText(catFile, ['-t', `refs/tags/${named}`], repo)?.trim();
|
|
83
|
-
const head = gitText(revParseQuery, [`refs/tags/${named}^{commit}`], repo)?.trim();
|
|
84
|
-
if (!kind || !head) return releaseUnknown(`release tag ${named} is unreadable`);
|
|
85
|
-
if (kind !== 'tag') return ineligibleRelease(explicit, `release tag ${named} is not annotated`);
|
|
86
|
-
const version = versionAt(repo, head);
|
|
87
|
-
if (!version) return releaseUnknown(`release manifest of ${named} is unreadable`);
|
|
88
|
-
if (named !== `v${version}`) return ineligibleRelease(explicit, `release tag ${named} differs from its manifest version ${version}`);
|
|
89
|
-
const reachable = releaseAncestor(repo, head, current);
|
|
90
|
-
if (reachable === null) return releaseUnknown(`release ancestry of ${named} is unreadable`);
|
|
91
|
-
if (!reachable) return ineligibleRelease(explicit, `release tag ${named} is not merged in HEAD`);
|
|
92
|
-
if (excludeHead && head === current) return { ok: true, candidate: null };
|
|
93
|
-
return { ok: true, candidate: { tag: named, head } };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Only one unsuperseded release ancestor proves a boundary; incomparable releases hold. */
|
|
97
|
-
function nearestRelease(repo, candidates) {
|
|
98
|
-
if (!candidates.length) return releaseUnknown('no eligible annotated, manifest-bound release tag is merged in HEAD');
|
|
99
|
-
const nearest = [];
|
|
100
|
-
for (const candidate of candidates) {
|
|
101
|
-
const superseded = candidateSuperseded(repo, candidate, candidates);
|
|
102
|
-
if (superseded === null) return releaseUnknown('release ancestry between tags is unreadable');
|
|
103
|
-
if (!superseded) nearest.push(candidate);
|
|
104
|
-
}
|
|
105
|
-
if (nearest.length !== 1) return releaseUnknown(`release history is ambiguous: ${nearest.map((entry) => entry.tag).join(', ')}`);
|
|
106
|
-
return { ok: true, ...nearest[0] };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** A release loses its boundary only to a proven descendant release, never to a date or version sort. */
|
|
110
|
-
function candidateSuperseded(repo, candidate, candidates) {
|
|
111
|
-
for (const other of candidates) {
|
|
112
|
-
if (candidate.head === other.head) continue;
|
|
113
|
-
const older = releaseAncestor(repo, candidate.head, other.head);
|
|
114
|
-
if (older === null) return null;
|
|
115
|
-
if (older) return true;
|
|
116
|
-
}
|
|
117
|
-
return false;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Missing Git proof is distinct from a proven non-ancestor. */
|
|
121
|
-
function releaseAncestor(repo, before, after) {
|
|
122
|
-
const result = mergeBaseQuery(['--is-ancestor', before, after], { cwd: repo });
|
|
123
|
-
if (result.status === 0) return true;
|
|
124
|
-
if (result.status === 1) return false;
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** The next releases named from the released versions, for a fix text: "v<wave> or v<line>". */
|
|
129
|
-
const nextNamed = (released, version = null) => {
|
|
130
|
-
const next = nextReleases(released, version);
|
|
131
|
-
return next ? `v${next.wave} or v${next.line}` : 'v<new version>';
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
function versionFindings({ cwd, remoteCommit, version, released }) {
|
|
135
|
-
if (!remoteCommit) return [];
|
|
136
|
-
const remoteVersion = versionAt(cwd, remoteCommit);
|
|
137
|
-
if (remoteVersion === null) return [finding('RELEASE_REMOTE_HEAD_UNKNOWN', `the remote main head ${remoteCommit.slice(0, 9)} is not in this repository, so its version cannot be compared`, 'git fetch origin main')];
|
|
138
|
-
if (remoteVersion === version) return [finding('RELEASE_VERSION_UNCHANGED', `package.json version ${version} is the version the remote main already carries`, `set package.json version to the next release, move the CHANGELOG section to it, commit, then ${CUT_COMMAND(nextNamed(released, version))}`)];
|
|
139
|
-
return [];
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** The version must be the next release after the highest v* tag other than its own: a wave alpha.N.M is counted, never guessed. */
|
|
143
|
-
function nextReleaseFindings({ version, released }) {
|
|
144
|
-
if (released === null) return [finding('RELEASE_VERSION_NOT_NEXT', 'the v* tags are unreadable, so the next release cannot be derived', 'git fetch --tags')];
|
|
145
|
-
const refusal = releaseVersionRefusal(version, released);
|
|
146
|
-
return refusal ? [finding('RELEASE_VERSION_NOT_NEXT', refusal, `set package.json version to the next release, move the CHANGELOG section to it, commit, then ${CUT_COMMAND(nextNamed(released.filter((entry) => entry !== version), version))}`)] : [];
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function changelogFindings({ cwd, commit, version, tag }) {
|
|
150
|
-
const changelog = gitText(show, [`${commit}:CHANGELOG.md`], cwd);
|
|
151
|
-
if (changelog === null) return [finding('RELEASE_CHANGELOG_SECTION', 'CHANGELOG.md is not readable at the pushed commit', 'restore CHANGELOG.md')];
|
|
152
|
-
const notes = releaseNotesFindings({ tags: [tag], changelog });
|
|
153
|
-
if (notes.length) return notes.map((n) => finding('RELEASE_CHANGELOG_SECTION', n.message, 'finish the CHANGELOG section of the release (no in-preparation mark, no open marker), commit'));
|
|
154
|
-
if (!changelogSection(changelog, version).found || !DATED_HEADING(version).test(changelog)) return [finding('RELEASE_CHANGELOG_SECTION', `CHANGELOG.md has no dated heading "## [${version}] — YYYY-MM-DD"`, `date the heading of the ${version} section, commit`)];
|
|
155
|
-
return [];
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function tagFindings({ cwd, commit, tag }) {
|
|
159
|
-
const kind = gitText(catFile, ['-t', `refs/tags/${tag}`], cwd)?.trim();
|
|
160
|
-
const target = gitText(revParseQuery, [`refs/tags/${tag}^{commit}`], cwd)?.trim();
|
|
161
|
-
if (kind === 'tag' && target === commit) return [];
|
|
162
|
-
return [finding('RELEASE_TAG_MISSING', `no annotated tag ${tag} points at ${commit.slice(0, 9)}`, CUT_COMMAND(tag))];
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** The finding of a record that claims the delegated mode without the checked-out config saying so (or without naming the delegated linux-parity row), else null. */
|
|
166
|
-
function suiteModeFinding({ record, cwd, commit, tag }) {
|
|
167
|
-
if (record.suite !== 'ci') return null;
|
|
168
|
-
if (suiteModeOf(cwd) !== 'ci') return finding('RELEASE_SUITE_MODE', `the release record of ${commit.slice(0, 9)} says suite: ci, but this checkout's config.yaml release.suite is not ci: a record does not choose the mode`, `set release.suite: ci in config.yaml (the owner's choice), or ${CUT_COMMAND(tag)} under suite: local`);
|
|
169
|
-
if (!(record.delegated ?? []).some((row) => row?.name === 'linux-parity')) return finding('RELEASE_SUITE_MODE', `the suite: ci record of ${commit.slice(0, 9)} does not list linux-parity as delegated`, CUT_COMMAND(tag));
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function receiptFindings({ cwd, commit, tag }) {
|
|
174
|
-
const record = readL4Record({ repo: cwd, head: commit, tag });
|
|
175
|
-
if (!record) return [finding('RELEASE_RECEIPT_MISSING', `no release record for exactly ${commit.slice(0, 9)} and ${tag}: the package clean-test, the scaffold e2e and the check did not run green on this commit`, CUT_COMMAND(tag))];
|
|
176
|
-
const mode = suiteModeFinding({ record, cwd, commit, tag });
|
|
177
|
-
if (mode) return [mode];
|
|
178
|
-
// A row reused from another commit (reusedFrom) counts: the cut reuses a row only when its input digest is identical (owner ruling speed-flow, 2026-10-25), and the record names the commit it came from.
|
|
179
|
-
const green = new Set((record.logs ?? []).filter((row) => row?.ok === true).map((row) => row.name));
|
|
180
|
-
const absent = RECEIPT_STEPS.filter((name) => !green.has(name));
|
|
181
|
-
return absent.length ? [finding('RELEASE_RECEIPT_INCOMPLETE', `the release record of ${commit.slice(0, 9)} has no green row for: ${absent.join(', ')}`, CUT_COMMAND(tag))] : [];
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* The findings that keep `commit` from being a release commit of the repository at `cwd`: [{code, missing, fix}], [] when it is one.
|
|
186
|
-
* `remoteCommit` is the remote main's head being replaced (null for a tag push or a first push); `tag` the release tag (default v<version>);
|
|
187
|
-
* `needTag` / `needReceipt` are false only for the cut's early look, before it has created the tag and run the suite.
|
|
188
|
-
*/
|
|
189
|
-
export function releaseFindings({ cwd, commit, remoteCommit = null, tag = null, needTag = true, needReceipt = true }) {
|
|
190
|
-
const version = versionAt(cwd, commit);
|
|
191
|
-
if (!version) return [finding('RELEASE_VERSION_UNREADABLE', `package.json at ${commit.slice(0, 9)} has no readable version`, 'restore package.json')];
|
|
192
|
-
const expected = `v${version}`;
|
|
193
|
-
const named = tag ?? expected;
|
|
194
|
-
if (named !== expected) return [finding('RELEASE_TAG_VERSION', `the tag ${named} is not v${version}, the version of ${commit.slice(0, 9)}`, `tag v${version}, or set package.json version to ${named.replace(/^v/, '')}`)];
|
|
195
|
-
const listed = gitText(gitTag, ['--list', 'v*'], cwd);
|
|
196
|
-
const released = listed === null ? null : taggedVersions(listed);
|
|
197
|
-
return [
|
|
198
|
-
...versionFindings({ cwd, remoteCommit, version, released: released ?? [] }),
|
|
199
|
-
...nextReleaseFindings({ version, released }),
|
|
200
|
-
...changelogFindings({ cwd, commit, version, tag: named }),
|
|
201
|
-
...(needTag ? tagFindings({ cwd, commit, tag: named }) : []),
|
|
202
|
-
...(needReceipt ? receiptFindings({ cwd, commit, tag: named }) : []),
|
|
203
|
-
];
|
|
204
|
-
}
|
|
1
|
+
// release-definition.mjs - what makes a commit of the runtime repository a RELEASE commit, the one definition shared by the
|
|
2
|
+
// pre-push hook (scripts/guards/release-push-gate.mjs) and the release cut (scripts/supervisor/release-cut.mjs). The remote
|
|
3
|
+
// main of the runtime moves exactly when a release is cut, so a push of main or of a v* tag is judged against this list:
|
|
4
|
+
// RELEASE_VERSION_UNCHANGED package.json `version` at the pushed commit differs from the version at the remote main's head
|
|
5
|
+
// RELEASE_VERSION_NOT_NEXT that version is the next release after the highest v* tag (scripts/lib/release-version.mjs, owner ruling release-waves):
|
|
6
|
+
// the next wave alpha.N.M+1 of its line or the next line alpha.N+1, never the legacy -fix form
|
|
7
|
+
// RELEASE_TAG_VERSION the release tag is v<version> of the pushed commit
|
|
8
|
+
// RELEASE_CHANGELOG_SECTION CHANGELOG.md holds `## [<version>] - <date>` (a dated heading) with no unfinished marker (R222)
|
|
9
|
+
// RELEASE_TAG_MISSING an ANNOTATED tag v<version> points at the pushed commit
|
|
10
|
+
// RELEASE_RECEIPT_MISSING the L4 record of exactly this commit (scripts/guards/release-record.mjs, written by the cut) names this tag
|
|
11
|
+
// RELEASE_RECEIPT_INCOMPLETE that record holds a green row for each of RECEIPT_STEPS (the package clean-test, the scaffold e2e and the checks) (RAN on this commit or reused from another with the identical input digest, the record's `reusedFrom` names it; owner ruling speed-flow, 2026-10-25)
|
|
12
|
+
// RELEASE_SUITE_MODE the record says `suite: ci` (the Linux parity delegated to CI) while the checked-out config says `local`, or it lists no delegated linux-parity: a record never claims the mode by
|
|
13
|
+
// itself (scripts/guards/release-suite-mode.mjs); both modes ask for the same root rows, the runtime has no spec suite to delegate (owner ruling no-runtime-specs)
|
|
14
|
+
// Each finding names what is missing and the command that produces it. Pure over the git reads; no bypass.
|
|
15
|
+
import { catFile } from '../api/git/cat-file.mjs';
|
|
16
|
+
import { revParseQuery } from '../api/git/rev-parse-query.mjs';
|
|
17
|
+
import { show } from '../api/git/show.mjs';
|
|
18
|
+
import { tag as gitTag } from '../api/git/tag.mjs';
|
|
19
|
+
import { mergeBaseQuery } from '../api/git/merge-base-query.mjs';
|
|
20
|
+
import { pathKey, realPath } from '../lib/path-key.mjs';
|
|
21
|
+
import { changelogSection, releaseNotesFindings } from '../hfs/runtime-rules/release-notes.mjs';
|
|
22
|
+
import { nextReleases, releaseVersionRefusal, taggedVersions } from '../lib/release-version.mjs';
|
|
23
|
+
import { readL4Record } from './release-record.mjs';
|
|
24
|
+
import { suiteModeOf } from './release-suite-mode.mjs';
|
|
25
|
+
|
|
26
|
+
/** The rows the L4 record must hold green, run on the commit or reused from an identical-digest row of another, in either suite mode: the package clean-test, the scaffold e2e and the checks (the runtime's real tests). */
|
|
27
|
+
export const RECEIPT_STEPS = Object.freeze(['npm run test:packages', 'npm run test:e2e', 'npm run check']);
|
|
28
|
+
const CUT_COMMAND = (tag) => `starci release cut --tag ${tag}`;
|
|
29
|
+
const REGEX_SPECIALS = /[.*+?^$()|[\]{}\\]/g;
|
|
30
|
+
const HEADING_HEAD = String.raw`^## \[`;
|
|
31
|
+
const DATED_HEADING_TAIL = String.raw`\] — \d{4}-\d{2}-\d{2}\s*$`;
|
|
32
|
+
const DATED_HEADING = (version) => new RegExp([HEADING_HEAD, version.replaceAll(REGEX_SPECIALS, String.raw`\$&`), DATED_HEADING_TAIL].join(''), 'mu');
|
|
33
|
+
|
|
34
|
+
const finding = (code, missing, fix) => ({ code, missing, fix });
|
|
35
|
+
const gitText = (verb, args, cwd) => {
|
|
36
|
+
const r = verb(args, { cwd, timeout: 60_000 });
|
|
37
|
+
return r.status === 0 ? String(r.stdout ?? '') : null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** The package.json version of `commit`, or null when the commit or its manifest cannot be read. */
|
|
41
|
+
export function versionAt(cwd, commit) {
|
|
42
|
+
const text = gitText(show, [`${commit}:package.json`], cwd);
|
|
43
|
+
try { return text === null ? null : JSON.parse(text).version ?? null; } catch { return null; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const releaseUnknown = (why) => ({ ok: false, status: 'unknown', why });
|
|
47
|
+
const ineligibleRelease = (explicit, why) => explicit ? releaseUnknown(why) : { ok: true, candidate: null };
|
|
48
|
+
|
|
49
|
+
/** A runtime Git root's nearest annotated, manifest-bound release; enclosing application history is inapplicable. */
|
|
50
|
+
export function releaseTagOf({ repo, tag = null, excludeHead = false }) {
|
|
51
|
+
const history = runtimeGitHistory(repo);
|
|
52
|
+
if (!history.ok) return history;
|
|
53
|
+
const listed = tag === null ? gitText(gitTag, ['--merged', 'HEAD', '--list', 'v*'], repo) : tag;
|
|
54
|
+
if (listed === null) return releaseUnknown('release tags are unreadable');
|
|
55
|
+
const candidates = [];
|
|
56
|
+
for (const named of listed.split(/\r?\n/).filter(Boolean)) {
|
|
57
|
+
const result = releaseCandidate({ repo, named, current: history.head, explicit: tag !== null, excludeHead });
|
|
58
|
+
if (!result.ok) return result;
|
|
59
|
+
if (result.candidate) candidates.push(result.candidate);
|
|
60
|
+
}
|
|
61
|
+
return nearestRelease(repo, candidates);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Runtime history exists only at its own Git top-level with a readable HEAD and full ancestry. */
|
|
65
|
+
function runtimeGitHistory(repo) {
|
|
66
|
+
const top = revParseQuery(['--show-toplevel'], { cwd: repo });
|
|
67
|
+
if (top.status !== 0) {
|
|
68
|
+
if (/not a git repository/i.test(String(top.stderr ?? ''))) return { ok: false, status: 'no-repository', why: 'release history requires a Git repository' };
|
|
69
|
+
return releaseUnknown('release repository identity is unreadable');
|
|
70
|
+
}
|
|
71
|
+
const gitRoot = String(top.stdout ?? '').trim();
|
|
72
|
+
if (!gitRoot) return releaseUnknown('release repository root is unreadable');
|
|
73
|
+
if (pathKey(realPath(gitRoot)) !== pathKey(realPath(repo))) return { ok: false, status: 'no-runtime-repository', why: 'the runtime copy is inside an enclosing Git repository; its history belongs to that repository' };
|
|
74
|
+
const head = gitText(revParseQuery, ['HEAD'], repo)?.trim();
|
|
75
|
+
if (!head) return releaseUnknown('release history HEAD is unreadable');
|
|
76
|
+
if (gitText(revParseQuery, ['--is-shallow-repository'], repo)?.trim() !== 'false') return releaseUnknown('release history is shallow or unreadable; fetch complete release history');
|
|
77
|
+
return { ok: true, head };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A candidate binds annotation, peeled commit, manifest spelling and ancestry; default discovery omits ineligible tags. */
|
|
81
|
+
function releaseCandidate({ repo, named, current, explicit, excludeHead }) {
|
|
82
|
+
const kind = gitText(catFile, ['-t', `refs/tags/${named}`], repo)?.trim();
|
|
83
|
+
const head = gitText(revParseQuery, [`refs/tags/${named}^{commit}`], repo)?.trim();
|
|
84
|
+
if (!kind || !head) return releaseUnknown(`release tag ${named} is unreadable`);
|
|
85
|
+
if (kind !== 'tag') return ineligibleRelease(explicit, `release tag ${named} is not annotated`);
|
|
86
|
+
const version = versionAt(repo, head);
|
|
87
|
+
if (!version) return releaseUnknown(`release manifest of ${named} is unreadable`);
|
|
88
|
+
if (named !== `v${version}`) return ineligibleRelease(explicit, `release tag ${named} differs from its manifest version ${version}`);
|
|
89
|
+
const reachable = releaseAncestor(repo, head, current);
|
|
90
|
+
if (reachable === null) return releaseUnknown(`release ancestry of ${named} is unreadable`);
|
|
91
|
+
if (!reachable) return ineligibleRelease(explicit, `release tag ${named} is not merged in HEAD`);
|
|
92
|
+
if (excludeHead && head === current) return { ok: true, candidate: null };
|
|
93
|
+
return { ok: true, candidate: { tag: named, head } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Only one unsuperseded release ancestor proves a boundary; incomparable releases hold. */
|
|
97
|
+
function nearestRelease(repo, candidates) {
|
|
98
|
+
if (!candidates.length) return releaseUnknown('no eligible annotated, manifest-bound release tag is merged in HEAD');
|
|
99
|
+
const nearest = [];
|
|
100
|
+
for (const candidate of candidates) {
|
|
101
|
+
const superseded = candidateSuperseded(repo, candidate, candidates);
|
|
102
|
+
if (superseded === null) return releaseUnknown('release ancestry between tags is unreadable');
|
|
103
|
+
if (!superseded) nearest.push(candidate);
|
|
104
|
+
}
|
|
105
|
+
if (nearest.length !== 1) return releaseUnknown(`release history is ambiguous: ${nearest.map((entry) => entry.tag).join(', ')}`);
|
|
106
|
+
return { ok: true, ...nearest[0] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A release loses its boundary only to a proven descendant release, never to a date or version sort. */
|
|
110
|
+
function candidateSuperseded(repo, candidate, candidates) {
|
|
111
|
+
for (const other of candidates) {
|
|
112
|
+
if (candidate.head === other.head) continue;
|
|
113
|
+
const older = releaseAncestor(repo, candidate.head, other.head);
|
|
114
|
+
if (older === null) return null;
|
|
115
|
+
if (older) return true;
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Missing Git proof is distinct from a proven non-ancestor. */
|
|
121
|
+
function releaseAncestor(repo, before, after) {
|
|
122
|
+
const result = mergeBaseQuery(['--is-ancestor', before, after], { cwd: repo });
|
|
123
|
+
if (result.status === 0) return true;
|
|
124
|
+
if (result.status === 1) return false;
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The next releases named from the released versions, for a fix text: "v<wave> or v<line>". */
|
|
129
|
+
const nextNamed = (released, version = null) => {
|
|
130
|
+
const next = nextReleases(released, version);
|
|
131
|
+
return next ? `v${next.wave} or v${next.line}` : 'v<new version>';
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
function versionFindings({ cwd, remoteCommit, version, released }) {
|
|
135
|
+
if (!remoteCommit) return [];
|
|
136
|
+
const remoteVersion = versionAt(cwd, remoteCommit);
|
|
137
|
+
if (remoteVersion === null) return [finding('RELEASE_REMOTE_HEAD_UNKNOWN', `the remote main head ${remoteCommit.slice(0, 9)} is not in this repository, so its version cannot be compared`, 'git fetch origin main')];
|
|
138
|
+
if (remoteVersion === version) return [finding('RELEASE_VERSION_UNCHANGED', `package.json version ${version} is the version the remote main already carries`, `set package.json version to the next release, move the CHANGELOG section to it, commit, then ${CUT_COMMAND(nextNamed(released, version))}`)];
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The version must be the next release after the highest v* tag other than its own: a wave alpha.N.M is counted, never guessed. */
|
|
143
|
+
function nextReleaseFindings({ version, released }) {
|
|
144
|
+
if (released === null) return [finding('RELEASE_VERSION_NOT_NEXT', 'the v* tags are unreadable, so the next release cannot be derived', 'git fetch --tags')];
|
|
145
|
+
const refusal = releaseVersionRefusal(version, released);
|
|
146
|
+
return refusal ? [finding('RELEASE_VERSION_NOT_NEXT', refusal, `set package.json version to the next release, move the CHANGELOG section to it, commit, then ${CUT_COMMAND(nextNamed(released.filter((entry) => entry !== version), version))}`)] : [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function changelogFindings({ cwd, commit, version, tag }) {
|
|
150
|
+
const changelog = gitText(show, [`${commit}:CHANGELOG.md`], cwd);
|
|
151
|
+
if (changelog === null) return [finding('RELEASE_CHANGELOG_SECTION', 'CHANGELOG.md is not readable at the pushed commit', 'restore CHANGELOG.md')];
|
|
152
|
+
const notes = releaseNotesFindings({ tags: [tag], changelog });
|
|
153
|
+
if (notes.length) return notes.map((n) => finding('RELEASE_CHANGELOG_SECTION', n.message, 'finish the CHANGELOG section of the release (no in-preparation mark, no open marker), commit'));
|
|
154
|
+
if (!changelogSection(changelog, version).found || !DATED_HEADING(version).test(changelog)) return [finding('RELEASE_CHANGELOG_SECTION', `CHANGELOG.md has no dated heading "## [${version}] — YYYY-MM-DD"`, `date the heading of the ${version} section, commit`)];
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function tagFindings({ cwd, commit, tag }) {
|
|
159
|
+
const kind = gitText(catFile, ['-t', `refs/tags/${tag}`], cwd)?.trim();
|
|
160
|
+
const target = gitText(revParseQuery, [`refs/tags/${tag}^{commit}`], cwd)?.trim();
|
|
161
|
+
if (kind === 'tag' && target === commit) return [];
|
|
162
|
+
return [finding('RELEASE_TAG_MISSING', `no annotated tag ${tag} points at ${commit.slice(0, 9)}`, CUT_COMMAND(tag))];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The finding of a record that claims the delegated mode without the checked-out config saying so (or without naming the delegated linux-parity row), else null. */
|
|
166
|
+
function suiteModeFinding({ record, cwd, commit, tag }) {
|
|
167
|
+
if (record.suite !== 'ci') return null;
|
|
168
|
+
if (suiteModeOf(cwd) !== 'ci') return finding('RELEASE_SUITE_MODE', `the release record of ${commit.slice(0, 9)} says suite: ci, but this checkout's config.yaml release.suite is not ci: a record does not choose the mode`, `set release.suite: ci in config.yaml (the owner's choice), or ${CUT_COMMAND(tag)} under suite: local`);
|
|
169
|
+
if (!(record.delegated ?? []).some((row) => row?.name === 'linux-parity')) return finding('RELEASE_SUITE_MODE', `the suite: ci record of ${commit.slice(0, 9)} does not list linux-parity as delegated`, CUT_COMMAND(tag));
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function receiptFindings({ cwd, commit, tag }) {
|
|
174
|
+
const record = readL4Record({ repo: cwd, head: commit, tag });
|
|
175
|
+
if (!record) return [finding('RELEASE_RECEIPT_MISSING', `no release record for exactly ${commit.slice(0, 9)} and ${tag}: the package clean-test, the scaffold e2e and the check did not run green on this commit`, CUT_COMMAND(tag))];
|
|
176
|
+
const mode = suiteModeFinding({ record, cwd, commit, tag });
|
|
177
|
+
if (mode) return [mode];
|
|
178
|
+
// A row reused from another commit (reusedFrom) counts: the cut reuses a row only when its input digest is identical (owner ruling speed-flow, 2026-10-25), and the record names the commit it came from.
|
|
179
|
+
const green = new Set((record.logs ?? []).filter((row) => row?.ok === true).map((row) => row.name));
|
|
180
|
+
const absent = RECEIPT_STEPS.filter((name) => !green.has(name));
|
|
181
|
+
return absent.length ? [finding('RELEASE_RECEIPT_INCOMPLETE', `the release record of ${commit.slice(0, 9)} has no green row for: ${absent.join(', ')}`, CUT_COMMAND(tag))] : [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The findings that keep `commit` from being a release commit of the repository at `cwd`: [{code, missing, fix}], [] when it is one.
|
|
186
|
+
* `remoteCommit` is the remote main's head being replaced (null for a tag push or a first push); `tag` the release tag (default v<version>);
|
|
187
|
+
* `needTag` / `needReceipt` are false only for the cut's early look, before it has created the tag and run the suite.
|
|
188
|
+
*/
|
|
189
|
+
export function releaseFindings({ cwd, commit, remoteCommit = null, tag = null, needTag = true, needReceipt = true }) {
|
|
190
|
+
const version = versionAt(cwd, commit);
|
|
191
|
+
if (!version) return [finding('RELEASE_VERSION_UNREADABLE', `package.json at ${commit.slice(0, 9)} has no readable version`, 'restore package.json')];
|
|
192
|
+
const expected = `v${version}`;
|
|
193
|
+
const named = tag ?? expected;
|
|
194
|
+
if (named !== expected) return [finding('RELEASE_TAG_VERSION', `the tag ${named} is not v${version}, the version of ${commit.slice(0, 9)}`, `tag v${version}, or set package.json version to ${named.replace(/^v/, '')}`)];
|
|
195
|
+
const listed = gitText(gitTag, ['--list', 'v*'], cwd);
|
|
196
|
+
const released = listed === null ? null : taggedVersions(listed);
|
|
197
|
+
return [
|
|
198
|
+
...versionFindings({ cwd, remoteCommit, version, released: released ?? [] }),
|
|
199
|
+
...nextReleaseFindings({ version, released }),
|
|
200
|
+
...changelogFindings({ cwd, commit, version, tag: named }),
|
|
201
|
+
...(needTag ? tagFindings({ cwd, commit, tag: named }) : []),
|
|
202
|
+
...(needReceipt ? receiptFindings({ cwd, commit, tag: named }) : []),
|
|
203
|
+
];
|
|
204
|
+
}
|
|
@@ -307,19 +307,48 @@ function singleFromRelation(tokens, level, from) {
|
|
|
307
307
|
return true;
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
|
|
310
|
+
// Functions that return a set of rows when they stand in a select list. A select list holding one multiplies the rows.
|
|
311
|
+
const SET_RETURNING = new Set(`GENERATE_SERIES GENERATE_SUBSCRIPTS UNNEST REGEXP_MATCHES REGEXP_SPLIT_TO_TABLE STRING_TO_TABLE
|
|
312
|
+
JSON_EACH JSONB_EACH JSON_EACH_TEXT JSONB_EACH_TEXT JSON_ARRAY_ELEMENTS JSONB_ARRAY_ELEMENTS JSON_ARRAY_ELEMENTS_TEXT JSONB_ARRAY_ELEMENTS_TEXT
|
|
313
|
+
JSON_OBJECT_KEYS JSONB_OBJECT_KEYS JSON_TO_RECORDSET JSONB_TO_RECORDSET JSON_POPULATE_RECORDSET JSONB_POPULATE_RECORDSET
|
|
314
|
+
JSONB_PATH_QUERY JSONB_PATH_QUERY_TZ JSON_TABLE PG_LS_DIR PG_LS_WALDIR PG_LS_LOGDIR PG_LISTENING_CHANNELS PG_SHOW_ALL_SETTINGS PG_GET_KEYWORDS ACLEXPLODE TS_STAT TS_DEBUG`.split(/\s+/u));
|
|
315
|
+
// Scalar builtins and operator-like words that are no row-multiplying call; the aggregate-only shape accepts these around its aggregates.
|
|
316
|
+
const SCALAR_CALLS = new Set(`COALESCE NULLIF GREATEST LEAST ROUND CEIL CEILING FLOOR ABS TRUNC EXTRACT DATE_PART DATE_TRUNC TO_CHAR LOWER UPPER LENGTH CONCAT
|
|
317
|
+
JSONB_BUILD_OBJECT JSONB_BUILD_ARRAY JSON_BUILD_OBJECT JSON_BUILD_ARRAY TO_JSONB TO_JSON`.split(/\s+/u));
|
|
318
|
+
const NOT_A_CALL = new Set(['IN', 'ANY', 'ALL', 'SOME', 'AND', 'OR', 'NOT', 'FILTER', 'EXISTS', 'CASE', 'WHEN', 'THEN', 'ELSE', 'AS', 'ON', 'DISTINCT', 'BETWEEN', 'LIKE', 'ILIKE', 'IS', 'WITHIN', 'FROM']);
|
|
319
|
+
|
|
320
|
+
const callAt = (tokens, index) => isName(tokens[index]) && tokens[index + 1]?.v === '(' && !(tokens[index].t === 'word' && (NOT_A_CALL.has(tokens[index].up) || CLAUSE_WORDS.has(tokens[index].up)));
|
|
321
|
+
|
|
322
|
+
/** A call to a set-returning function anywhere in the statement (conservative: nested positions count). */
|
|
323
|
+
const hasSetReturningCall = tokens => tokens.some((token, index) => callAt(tokens, index) && token.t === 'word' && SET_RETURNING.has(token.up));
|
|
324
|
+
|
|
325
|
+
const hasWindow = tokens => tokens.some(token => isWord(token, 'OVER'));
|
|
326
|
+
|
|
327
|
+
/** Plain known aggregate calls and known scalar builtins are scalar; other calls, including wrappers around aggregates, may return sets. */
|
|
311
328
|
function unprovenProjectionCall(tokens, level, select, from) {
|
|
312
329
|
const end = from < 0 ? tokens.length : from;
|
|
313
330
|
for (let index = select + 1; index < end; index += 1) {
|
|
314
331
|
const token = tokens[index];
|
|
315
332
|
if (level[index] === 0 && (isWord(token, 'WHERE') || (token.t === 'word' && WHERE_END.has(token.up)))) break;
|
|
316
|
-
if (!
|
|
333
|
+
if (!callAt(tokens, index)) continue;
|
|
317
334
|
if (scalarCastAt(tokens, index)) continue;
|
|
318
|
-
|
|
335
|
+
const known = token.t === 'word' && tokens[index - 1]?.v !== '.' && (AGGREGATES.has(token.up) || SCALAR_CALLS.has(token.up));
|
|
336
|
+
if (!known) return true;
|
|
319
337
|
}
|
|
320
338
|
return false;
|
|
321
339
|
}
|
|
322
340
|
|
|
341
|
+
/**
|
|
342
|
+
* A SELECT that returns at most one row by construction: it has no FROM (one row, unless a set-returning function multiplies it),
|
|
343
|
+
* or it selects only aggregates (plain or inside known scalar calls and casts) with no GROUP BY, window or set-returning function.
|
|
344
|
+
*/
|
|
345
|
+
function singleRowBound(tokens, level, select, from, grouped) {
|
|
346
|
+
if (hasSetReturningCall(tokens) || hasWindow(tokens)) return null;
|
|
347
|
+
if (from < 0) return grouped ? null : { bounded: true, basis: 'no from' };
|
|
348
|
+
if (!grouped && !unprovenProjectionCall(tokens, level, select, from) && aggregateSelectItems(tokens, level, select, from).every(isAggregateItem)) return { bounded: true, basis: 'aggregate' };
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
323
352
|
function selectBound(tokens, level, uniqueSetsOf, cte) {
|
|
324
353
|
if (findTopWord(tokens, level, 'LIMIT') >= 0 || findTopWord(tokens, level, 'FETCH') >= 0) return { bounded: true, basis: 'limit' };
|
|
325
354
|
const select = findTopWord(tokens, level, 'SELECT');
|
|
@@ -328,10 +357,10 @@ function selectBound(tokens, level, uniqueSetsOf, cte) {
|
|
|
328
357
|
const unbounded = { bounded: false, basis: 'unbounded', table: primary?.table ?? null };
|
|
329
358
|
const grouped = hasTopGroupBy(tokens, level);
|
|
330
359
|
if (tokens.some((token, index) => level[index] === 0 && (isWord(token, 'UNION') || isWord(token, 'INTERSECT') || isWord(token, 'EXCEPT')))) return unbounded;
|
|
331
|
-
|
|
332
|
-
if (
|
|
333
|
-
if (
|
|
334
|
-
if (grouped || !primary || primary.dynamic || cte.has(primary.table) || !singleFromRelation(tokens, level, from)) return unbounded;
|
|
360
|
+
const single = singleRowBound(tokens, level, select, from, grouped);
|
|
361
|
+
if (single) return single;
|
|
362
|
+
if (unprovenProjectionCall(tokens, level, select, from) || hasSetReturningCall(tokens)) return unbounded;
|
|
363
|
+
if (grouped || from < 0 || !primary || primary.dynamic || cte.has(primary.table) || !singleFromRelation(tokens, level, from)) return unbounded;
|
|
335
364
|
if (isUniqueBounded(tokens, level, from, primary, uniqueSetsOf(primary.table, primary.schema))) return { bounded: true, basis: 'unique' };
|
|
336
365
|
return unbounded;
|
|
337
366
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// followed through variables, parameters (state.arguments), array callbacks, for-of and literal tables.
|
|
3
3
|
import { nameText as nameOf } from '../../lib/ts-ast.mjs';
|
|
4
4
|
import { unwrapCatalogExpression as unwrap } from '../../lib/ts-analysis.mjs';
|
|
5
|
-
import { excludesNull, ownerProperty, propertiesOf, propertyValues, writesTarget } from './unused-symbol-properties.mjs';
|
|
5
|
+
import { assignmentTarget, excludesNull, ownerProperty, propertiesOf, propertyValues, writesTarget } from './unused-symbol-properties.mjs';
|
|
6
6
|
|
|
7
7
|
const ARRAY_CALLBACK = /^(?:map|flatMap|forEach|filter|find|some|every)$/u;
|
|
8
8
|
|
|
@@ -17,16 +17,50 @@ export const symbolAt = (state, node) => {
|
|
|
17
17
|
return symbol?.flags === state.ts.SymbolFlags.Alias ? state.checker.getAliasedSymbol(symbol) : symbol;
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Every declaration a source assigns to (`=`, compound assignment, `++`/`--`, for-in/of, destructuring targets): one walk per source,
|
|
22
|
+
* so a member's "is it ever written" is a lookup instead of a scan of the whole tree.
|
|
23
|
+
*/
|
|
24
|
+
export function writtenDeclarations(state, sources) {
|
|
25
|
+
const { ts } = state;
|
|
26
|
+
const written = new Set();
|
|
27
|
+
const record = target => {
|
|
28
|
+
const declaration = symbolAt(state, target)?.valueDeclaration;
|
|
29
|
+
if (declaration) written.add(declaration);
|
|
30
|
+
return false;
|
|
31
|
+
};
|
|
32
|
+
const visit = own => {
|
|
33
|
+
const assignment = ts.isBinaryExpression(own) && own.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && own.operatorToken.kind <= ts.SyntaxKind.LastAssignment;
|
|
34
|
+
const unary = (ts.isPrefixUnaryExpression(own) || ts.isPostfixUnaryExpression(own)) && [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(own.operator);
|
|
35
|
+
if (assignment) assignmentTarget(state, own.left, record);
|
|
36
|
+
else if (unary) assignmentTarget(state, own.operand, record);
|
|
37
|
+
else if (ts.isForOfStatement(own) || ts.isForInStatement(own)) assignmentTarget(state, own.initializer, record);
|
|
38
|
+
ts.forEachChild(own, visit);
|
|
39
|
+
};
|
|
40
|
+
for (const source of sources) visit(source);
|
|
41
|
+
return written;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether any analysed source assigns to the declaration; the index is built once per analysed source set. */
|
|
45
|
+
function isWritten(state, declaration) {
|
|
46
|
+
const sources = state.flowSources ? [...state.flowSources.values()] : null;
|
|
47
|
+
if (sources) {
|
|
48
|
+
if (state.writeIndexFor !== state.flowSources) { state.writeIndex = writtenDeclarations(state, sources); state.writeIndexFor = state.flowSources; }
|
|
49
|
+
return state.writeIndex.has(declaration);
|
|
50
|
+
}
|
|
51
|
+
const files = state.writeIndexByFile ??= new Map();
|
|
52
|
+
const file = declaration.getSourceFile();
|
|
53
|
+
if (!files.has(file)) files.set(file, writtenDeclarations(state, [file]));
|
|
54
|
+
return files.get(file).has(declaration);
|
|
55
|
+
}
|
|
56
|
+
|
|
20
57
|
/** Only a declared, unmodified class method or readonly field supplies instance-member provenance. */
|
|
21
58
|
function classMemberValue(state, declaration) {
|
|
22
59
|
const { ts } = state;
|
|
23
60
|
if (!declaration || !ts.isClassLike(declaration.parent)) return null;
|
|
24
61
|
const field = ts.isPropertyDeclaration(declaration);
|
|
25
62
|
if (!ts.isMethodDeclaration(declaration) && (!field || !declaration.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ReadonlyKeyword))) return null;
|
|
26
|
-
|
|
27
|
-
if (!mutations.has(declaration)) mutations.set(declaration, [...(state.flowSources?.values() ?? [declaration.getSourceFile()])]
|
|
28
|
-
.some(source => writesTarget(state, source, target => symbolAt(state, target)?.valueDeclaration === declaration)));
|
|
29
|
-
if (mutations.get(declaration)) return null;
|
|
63
|
+
if (isWritten(state, declaration)) return null;
|
|
30
64
|
return field ? unwrap(ts, declaration.initializer) : declaration;
|
|
31
65
|
}
|
|
32
66
|
|
|
@@ -86,7 +86,7 @@ function immutableReference(state, expression) {
|
|
|
86
86
|
return Boolean(declaration && ts.isVariableDeclaration(declaration) && (declaration.parent.flags & ts.NodeFlags.Const));
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
function assignmentTarget(state, expression, matches) {
|
|
89
|
+
export function assignmentTarget(state, expression, matches) {
|
|
90
90
|
const { ts } = state;
|
|
91
91
|
const node = unwrap(ts, expression);
|
|
92
92
|
if (!node) return false;
|
|
@@ -230,8 +230,20 @@ function skipsHooks(piece, code, strings) {
|
|
|
230
230
|
return around !== null && strings.some((other) => other.at > around[0] && other.at < around[1] && GIT_VERBS.has(other.text));
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
/**
|
|
234
|
-
const
|
|
233
|
+
/** Words after which a `[` opens an array literal, not a member access. */
|
|
234
|
+
const BEFORE_ARRAY_LITERAL = /(?:^|[^\w$.])(?:return|typeof|in|of|case|yield|await|void|new|delete|else|do|throw|default|extends)$/;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A test marker spelled as an index (`describe['skip']`): a string equal to a marker that is the whole key of a member access,
|
|
238
|
+
* `[` before it after an expression and `]` right after it. An array element (`["skip", "idea"]`, `const kinds = ['only']`) is data.
|
|
239
|
+
*/
|
|
240
|
+
function indexedMarker(piece, code) {
|
|
241
|
+
if (!TEST_KEYS.has(piece.text)) return false;
|
|
242
|
+
const before = code.slice(0, piece.at).trimEnd();
|
|
243
|
+
if (!before.endsWith('[') || !code.slice(piece.at + piece.text.length).trimStart().startsWith(']')) return false;
|
|
244
|
+
const subject = before.slice(0, -1).trimEnd();
|
|
245
|
+
return /[\w$)\]]$/.test(subject) && !BEFORE_ARRAY_LITERAL.test(subject);
|
|
246
|
+
}
|
|
235
247
|
|
|
236
248
|
/** An `exit` handler of the process that resets the exit code or swallows the failure. */
|
|
237
249
|
function exitHandler(piece, code) {
|
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
// release-version.mjs - the one reading of a release version (owner ruling release-waves). A prerelease `<label>.N` opens the release
|
|
2
|
-
// line N and `<label>.N.M` is the M-th release after it, a wave or a fix alike, so alpha.31 < alpha.31.1 < alpha.31.2 < alpha.32 in
|
|
3
|
-
// SemVer order. The next release is derived from the highest one already released, never guessed. The legacy `<label>.N-fix.K` still
|
|
4
|
-
// reads as release K after `<label>.N` and is never produced again. Pure: the callers read the tags and the registry.
|
|
5
|
-
|
|
6
|
-
const NUMERIC = /^(?:0|[1-9]\d*)$/;
|
|
7
|
-
const POSITIVE = /^[1-9]\d*$/;
|
|
8
|
-
const IDENTIFIERS = /^[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/;
|
|
9
|
-
const LEGACY_FIX = '-fix.';
|
|
10
|
-
|
|
11
|
-
/** {base, wave, legacy}: the release a version counts its waves from, and its wave M (0 for the release that opens the line). */
|
|
12
|
-
export function waveOf(version) {
|
|
13
|
-
const text = String(version ?? '').split('+')[0];
|
|
14
|
-
const fix = text.lastIndexOf(LEGACY_FIX);
|
|
15
|
-
const legacyWave = fix > 0 ? text.slice(fix + LEGACY_FIX.length) : '';
|
|
16
|
-
if (POSITIVE.test(legacyWave)) return { base: text.slice(0, fix), wave: Number(legacyWave), legacy: true };
|
|
17
|
-
const ids = text.split('.');
|
|
18
|
-
const counted = ids.length > 3 && POSITIVE.test(ids.at(-1)) && (text.includes('-') ? NUMERIC.test(ids.at(-2)) : ids.length === 4);
|
|
19
|
-
return counted ? { base: ids.slice(0, -1).join('.'), wave: Number(ids.at(-1)), legacy: false } : { base: text, wave: 0, legacy: false };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Parsed SemVer identifiers {core, pre} of `version`; an unreadable version throws. */
|
|
23
|
-
export function versionParts(version) {
|
|
24
|
-
const parts = typeof version === 'string' ? version.split('+') : [];
|
|
25
|
-
if (!parts.length || parts.length > 2 || (parts.length === 2 && !IDENTIFIERS.test(parts[1]))) throw new Error(`unreadable release version ${version}`);
|
|
26
|
-
const boundary = parts[0].indexOf('-');
|
|
27
|
-
const core = (boundary < 0 ? parts[0] : parts[0].slice(0, boundary)).split('.');
|
|
28
|
-
const prerelease = boundary < 0 ? null : parts[0].slice(boundary + 1);
|
|
29
|
-
if (core.length !== 3 || core.some((part) => !NUMERIC.test(part)) || (prerelease !== null && !IDENTIFIERS.test(prerelease))) throw new Error(`unreadable release version ${version}`);
|
|
30
|
-
const pre = prerelease?.split('.') ?? [];
|
|
31
|
-
if (pre.some((part) => /^0\d+$/.test(part))) throw new Error(`invalid numeric prerelease identifier in ${version}`);
|
|
32
|
-
return { core: core.map(BigInt), pre };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const readable = (version) => {
|
|
36
|
-
try {
|
|
37
|
-
versionParts(version);
|
|
38
|
-
return true;
|
|
39
|
-
} catch {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/** The SemVer spelling a version is ordered by: a legacy `-fix.K` orders as wave K of its line. */
|
|
45
|
-
const ordered = (version) => {
|
|
46
|
-
const { base, wave, legacy } = waveOf(version);
|
|
47
|
-
return legacy ? `${base}.${wave}` : version;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const compareIdentifier = (a, b) => {
|
|
51
|
-
const numericA = /^\d+$/.test(a), numericB = /^\d+$/.test(b);
|
|
52
|
-
if (numericA && numericB) return BigInt(a) < BigInt(b) ? -1 : Number(BigInt(a) > BigInt(b));
|
|
53
|
-
if (numericA !== numericB) return numericA ? -1 : 1;
|
|
54
|
-
return a < b ? -1 : Number(a > b);
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/** SemVer precedence, numeric prerelease identifiers included; build metadata has no precedence. */
|
|
58
|
-
export function compareVersions(a, b) {
|
|
59
|
-
const left = versionParts(ordered(a)), right = versionParts(ordered(b));
|
|
60
|
-
for (const i of left.core.keys()) {
|
|
61
|
-
if (left.core[i] !== right.core[i]) return left.core[i] < right.core[i] ? -1 : 1;
|
|
62
|
-
}
|
|
63
|
-
if (!left.pre.length || !right.pre.length) return Number(!left.pre.length) - Number(!right.pre.length);
|
|
64
|
-
for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i += 1) {
|
|
65
|
-
if (left.pre[i] === undefined) return -1;
|
|
66
|
-
if (right.pre[i] === undefined) return 1;
|
|
67
|
-
const compared = compareIdentifier(left.pre[i], right.pre[i]);
|
|
68
|
-
if (compared) return compared;
|
|
69
|
-
}
|
|
70
|
-
return 0;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** The release line of `version`: a prerelease's core and the identifiers before its first number (every alpha.N and alpha.N.M share one), a stable release's major and minor. */
|
|
74
|
-
export function releaseLine(version) {
|
|
75
|
-
const { core, pre } = versionParts(ordered(version));
|
|
76
|
-
if (!pre.length) return `${core.slice(0, 2).join('.')}-stable`;
|
|
77
|
-
const first = pre.findIndex((id) => /^\d+$/.test(id));
|
|
78
|
-
return `${core.join('.')}-${(first < 0 ? pre : pre.slice(0, first)).join('.')}`;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** {family, line} of a release that opens a line (`<x.y.z>-<label>.N`), or null for any other shape. */
|
|
82
|
-
function lineStart(base) {
|
|
83
|
-
if (!readable(base)) return null;
|
|
84
|
-
const { core, pre } = versionParts(base);
|
|
85
|
-
return pre.length === 2 && !/^\d+$/.test(pre[0]) && NUMERIC.test(pre[1]) ? { family: `${core.join('.')}-${pre[0]}`, line: Number(pre[1]) } : null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** The next wave after `version`: its base with M one past the highest wave of that base among `version` and `known`. */
|
|
89
|
-
export function nextWave(version, known = []) {
|
|
90
|
-
const { base } = waveOf(version);
|
|
91
|
-
const waves = [version, ...known].map(waveOf).filter((entry) => entry.base === base).map((entry) => entry.wave);
|
|
92
|
-
return `${base}.${Math.max(0, ...waves) + 1}`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** The family of a version: its core and label up to `-<label>` (`1.0.0-alpha`), or null for a stable or unlabelled version. */
|
|
96
|
-
export function familyOf(version) {
|
|
97
|
-
if (!readable(version)) return null;
|
|
98
|
-
const { core, pre } = versionParts(ordered(version));
|
|
99
|
-
return pre.length && !/^\d+$/.test(pre[0]) ? `${core.join('.')}-${pre[0]}` : null;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* What may follow the highest line-shaped release of `versions`: {highest, family, wave, line}, or null when none has that shape.
|
|
104
|
-
* With a `candidate`, a family that already has released tags is judged alone (another family's tags, such as legacy 2.0.1-alpha.N,
|
|
105
|
-
* never outrank the candidate's own line); a family without a tag falls back to the highest release overall.
|
|
106
|
-
*/
|
|
107
|
-
export function nextReleases(versions, candidate = null) {
|
|
108
|
-
const shaped = versions.filter((version) => readable(version) && lineStart(waveOf(version).base));
|
|
109
|
-
const family = candidate === null ? null : familyOf(candidate);
|
|
110
|
-
const own = family === null ? [] : shaped.filter((version) => lineStart(waveOf(version).base).family === family);
|
|
111
|
-
const pool = own.length ? own : shaped;
|
|
112
|
-
if (!pool.length) return null;
|
|
113
|
-
const highest = pool.reduce((top, version) => (compareVersions(version, top) > 0 ? version : top));
|
|
114
|
-
const start = lineStart(waveOf(highest).base);
|
|
115
|
-
return { highest, family: start.family, wave: nextWave(highest, pool), line: `${start.family}.${start.line + 1}` };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Why `version` cannot be released after `released` (the versions already tagged), or null when it can: the next wave of the highest
|
|
120
|
-
* release of its own family, the first release of the next line, or (when its family has no tag) a later release of another label or
|
|
121
|
-
* core than the highest. The legacy `-fix` form is always refused.
|
|
122
|
-
*/
|
|
123
|
-
export function releaseVersionRefusal(version, released) {
|
|
124
|
-
const next = nextReleases(released.filter((entry) => entry !== version), version);
|
|
125
|
-
const named = next ? `; the next release is ${next.wave} (the next wave or fix after ${next.highest}) or ${next.line} (the next line)` : '';
|
|
126
|
-
if (waveOf(version).legacy) return `${version} is the legacy -fix form, which is read and never released again${named}`;
|
|
127
|
-
if (!next || version === next.wave || version === next.line) return null;
|
|
128
|
-
if (!readable(version)) return `${version} is not a SemVer version${named}`;
|
|
129
|
-
if (lineStart(waveOf(version).base)?.family !== next.family && compareVersions(version, next.highest) > 0) return null;
|
|
130
|
-
return `${version} does not follow ${next.highest}${named}`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** The versions of the release tags `v<version>` in the output of `git tag --list`. */
|
|
134
|
-
export const taggedVersions = (listing) => String(listing ?? '').split(/\r?\n/).map((name) => name.trim()).filter((name) => /^v\d/.test(name)).map((name) => name.slice(1));
|
|
1
|
+
// release-version.mjs - the one reading of a release version (owner ruling release-waves). A prerelease `<label>.N` opens the release
|
|
2
|
+
// line N and `<label>.N.M` is the M-th release after it, a wave or a fix alike, so alpha.31 < alpha.31.1 < alpha.31.2 < alpha.32 in
|
|
3
|
+
// SemVer order. The next release is derived from the highest one already released, never guessed. The legacy `<label>.N-fix.K` still
|
|
4
|
+
// reads as release K after `<label>.N` and is never produced again. Pure: the callers read the tags and the registry.
|
|
5
|
+
|
|
6
|
+
const NUMERIC = /^(?:0|[1-9]\d*)$/;
|
|
7
|
+
const POSITIVE = /^[1-9]\d*$/;
|
|
8
|
+
const IDENTIFIERS = /^[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/;
|
|
9
|
+
const LEGACY_FIX = '-fix.';
|
|
10
|
+
|
|
11
|
+
/** {base, wave, legacy}: the release a version counts its waves from, and its wave M (0 for the release that opens the line). */
|
|
12
|
+
export function waveOf(version) {
|
|
13
|
+
const text = String(version ?? '').split('+')[0];
|
|
14
|
+
const fix = text.lastIndexOf(LEGACY_FIX);
|
|
15
|
+
const legacyWave = fix > 0 ? text.slice(fix + LEGACY_FIX.length) : '';
|
|
16
|
+
if (POSITIVE.test(legacyWave)) return { base: text.slice(0, fix), wave: Number(legacyWave), legacy: true };
|
|
17
|
+
const ids = text.split('.');
|
|
18
|
+
const counted = ids.length > 3 && POSITIVE.test(ids.at(-1)) && (text.includes('-') ? NUMERIC.test(ids.at(-2)) : ids.length === 4);
|
|
19
|
+
return counted ? { base: ids.slice(0, -1).join('.'), wave: Number(ids.at(-1)), legacy: false } : { base: text, wave: 0, legacy: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parsed SemVer identifiers {core, pre} of `version`; an unreadable version throws. */
|
|
23
|
+
export function versionParts(version) {
|
|
24
|
+
const parts = typeof version === 'string' ? version.split('+') : [];
|
|
25
|
+
if (!parts.length || parts.length > 2 || (parts.length === 2 && !IDENTIFIERS.test(parts[1]))) throw new Error(`unreadable release version ${version}`);
|
|
26
|
+
const boundary = parts[0].indexOf('-');
|
|
27
|
+
const core = (boundary < 0 ? parts[0] : parts[0].slice(0, boundary)).split('.');
|
|
28
|
+
const prerelease = boundary < 0 ? null : parts[0].slice(boundary + 1);
|
|
29
|
+
if (core.length !== 3 || core.some((part) => !NUMERIC.test(part)) || (prerelease !== null && !IDENTIFIERS.test(prerelease))) throw new Error(`unreadable release version ${version}`);
|
|
30
|
+
const pre = prerelease?.split('.') ?? [];
|
|
31
|
+
if (pre.some((part) => /^0\d+$/.test(part))) throw new Error(`invalid numeric prerelease identifier in ${version}`);
|
|
32
|
+
return { core: core.map(BigInt), pre };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const readable = (version) => {
|
|
36
|
+
try {
|
|
37
|
+
versionParts(version);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The SemVer spelling a version is ordered by: a legacy `-fix.K` orders as wave K of its line. */
|
|
45
|
+
const ordered = (version) => {
|
|
46
|
+
const { base, wave, legacy } = waveOf(version);
|
|
47
|
+
return legacy ? `${base}.${wave}` : version;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const compareIdentifier = (a, b) => {
|
|
51
|
+
const numericA = /^\d+$/.test(a), numericB = /^\d+$/.test(b);
|
|
52
|
+
if (numericA && numericB) return BigInt(a) < BigInt(b) ? -1 : Number(BigInt(a) > BigInt(b));
|
|
53
|
+
if (numericA !== numericB) return numericA ? -1 : 1;
|
|
54
|
+
return a < b ? -1 : Number(a > b);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** SemVer precedence, numeric prerelease identifiers included; build metadata has no precedence. */
|
|
58
|
+
export function compareVersions(a, b) {
|
|
59
|
+
const left = versionParts(ordered(a)), right = versionParts(ordered(b));
|
|
60
|
+
for (const i of left.core.keys()) {
|
|
61
|
+
if (left.core[i] !== right.core[i]) return left.core[i] < right.core[i] ? -1 : 1;
|
|
62
|
+
}
|
|
63
|
+
if (!left.pre.length || !right.pre.length) return Number(!left.pre.length) - Number(!right.pre.length);
|
|
64
|
+
for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i += 1) {
|
|
65
|
+
if (left.pre[i] === undefined) return -1;
|
|
66
|
+
if (right.pre[i] === undefined) return 1;
|
|
67
|
+
const compared = compareIdentifier(left.pre[i], right.pre[i]);
|
|
68
|
+
if (compared) return compared;
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The release line of `version`: a prerelease's core and the identifiers before its first number (every alpha.N and alpha.N.M share one), a stable release's major and minor. */
|
|
74
|
+
export function releaseLine(version) {
|
|
75
|
+
const { core, pre } = versionParts(ordered(version));
|
|
76
|
+
if (!pre.length) return `${core.slice(0, 2).join('.')}-stable`;
|
|
77
|
+
const first = pre.findIndex((id) => /^\d+$/.test(id));
|
|
78
|
+
return `${core.join('.')}-${(first < 0 ? pre : pre.slice(0, first)).join('.')}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** {family, line} of a release that opens a line (`<x.y.z>-<label>.N`), or null for any other shape. */
|
|
82
|
+
function lineStart(base) {
|
|
83
|
+
if (!readable(base)) return null;
|
|
84
|
+
const { core, pre } = versionParts(base);
|
|
85
|
+
return pre.length === 2 && !/^\d+$/.test(pre[0]) && NUMERIC.test(pre[1]) ? { family: `${core.join('.')}-${pre[0]}`, line: Number(pre[1]) } : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The next wave after `version`: its base with M one past the highest wave of that base among `version` and `known`. */
|
|
89
|
+
export function nextWave(version, known = []) {
|
|
90
|
+
const { base } = waveOf(version);
|
|
91
|
+
const waves = [version, ...known].map(waveOf).filter((entry) => entry.base === base).map((entry) => entry.wave);
|
|
92
|
+
return `${base}.${Math.max(0, ...waves) + 1}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The family of a version: its core and label up to `-<label>` (`1.0.0-alpha`), or null for a stable or unlabelled version. */
|
|
96
|
+
export function familyOf(version) {
|
|
97
|
+
if (!readable(version)) return null;
|
|
98
|
+
const { core, pre } = versionParts(ordered(version));
|
|
99
|
+
return pre.length && !/^\d+$/.test(pre[0]) ? `${core.join('.')}-${pre[0]}` : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What may follow the highest line-shaped release of `versions`: {highest, family, wave, line}, or null when none has that shape.
|
|
104
|
+
* With a `candidate`, a family that already has released tags is judged alone (another family's tags, such as legacy 2.0.1-alpha.N,
|
|
105
|
+
* never outrank the candidate's own line); a family without a tag falls back to the highest release overall.
|
|
106
|
+
*/
|
|
107
|
+
export function nextReleases(versions, candidate = null) {
|
|
108
|
+
const shaped = versions.filter((version) => readable(version) && lineStart(waveOf(version).base));
|
|
109
|
+
const family = candidate === null ? null : familyOf(candidate);
|
|
110
|
+
const own = family === null ? [] : shaped.filter((version) => lineStart(waveOf(version).base).family === family);
|
|
111
|
+
const pool = own.length ? own : shaped;
|
|
112
|
+
if (!pool.length) return null;
|
|
113
|
+
const highest = pool.reduce((top, version) => (compareVersions(version, top) > 0 ? version : top));
|
|
114
|
+
const start = lineStart(waveOf(highest).base);
|
|
115
|
+
return { highest, family: start.family, wave: nextWave(highest, pool), line: `${start.family}.${start.line + 1}` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Why `version` cannot be released after `released` (the versions already tagged), or null when it can: the next wave of the highest
|
|
120
|
+
* release of its own family, the first release of the next line, or (when its family has no tag) a later release of another label or
|
|
121
|
+
* core than the highest. The legacy `-fix` form is always refused.
|
|
122
|
+
*/
|
|
123
|
+
export function releaseVersionRefusal(version, released) {
|
|
124
|
+
const next = nextReleases(released.filter((entry) => entry !== version), version);
|
|
125
|
+
const named = next ? `; the next release is ${next.wave} (the next wave or fix after ${next.highest}) or ${next.line} (the next line)` : '';
|
|
126
|
+
if (waveOf(version).legacy) return `${version} is the legacy -fix form, which is read and never released again${named}`;
|
|
127
|
+
if (!next || version === next.wave || version === next.line) return null;
|
|
128
|
+
if (!readable(version)) return `${version} is not a SemVer version${named}`;
|
|
129
|
+
if (lineStart(waveOf(version).base)?.family !== next.family && compareVersions(version, next.highest) > 0) return null;
|
|
130
|
+
return `${version} does not follow ${next.highest}${named}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The versions of the release tags `v<version>` in the output of `git tag --list`. */
|
|
134
|
+
export const taggedVersions = (listing) => String(listing ?? '').split(/\r?\n/).map((name) => name.trim()).filter((name) => /^v\d/.test(name)).map((name) => name.slice(1));
|