mandrel 2.53.0 → 2.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +4 -2
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +158 -7
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +19 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/findings/route-finding.js +4 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +40 -16
- package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +8 -1
- package/.agents/workflows/mandrel-plan.md +4 -7
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +34 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -42,6 +42,39 @@ const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun']);
|
|
|
42
42
|
/** Script names that mean "the whole suite" rather than a scoped subset. */
|
|
43
43
|
const FULL_SUITE_SCRIPTS = new Set(['test', 'test:coverage']);
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Flags that narrow a runner's work to a subset of the suite (Story #5278).
|
|
47
|
+
*
|
|
48
|
+
* A positional path is not the only way to scope a run: Node's test runner
|
|
49
|
+
* takes filter flags that select a fraction of the tests while the argv still
|
|
50
|
+
* reads as a bare `node --test`. Crediting one of those against the full-suite
|
|
51
|
+
* stamp reports a filtered run as the whole suite — the exact false positive
|
|
52
|
+
* {@link isFullSuiteCommand} exists to refuse. Matched by prefix so both
|
|
53
|
+
* spellings (`--test-only`, `--test-name-pattern=x`, `--test-name-pattern x`)
|
|
54
|
+
* are caught.
|
|
55
|
+
*/
|
|
56
|
+
const NARROWING_FLAGS = Object.freeze([
|
|
57
|
+
'--test-name-pattern',
|
|
58
|
+
'--test-skip-pattern',
|
|
59
|
+
'--test-only',
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Does any token narrow the run to a subset of the suite? Pure helper for
|
|
64
|
+
* {@link isFullSuiteCommand}, split out so that function's own branching stays
|
|
65
|
+
* inside its committed cyclomatic budget.
|
|
66
|
+
*
|
|
67
|
+
* @param {string[]} tokens
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
function hasNarrowingFlag(tokens) {
|
|
71
|
+
return tokens.some((token) =>
|
|
72
|
+
NARROWING_FLAGS.some(
|
|
73
|
+
(flag) => token === flag || token.startsWith(`${flag}=`),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
45
78
|
/**
|
|
46
79
|
* Split a Story `verify[]` line into its command and its tier tag.
|
|
47
80
|
*
|
|
@@ -76,6 +109,10 @@ export function isFullSuiteCommand(command) {
|
|
|
76
109
|
.split(/\s+/)
|
|
77
110
|
.filter(Boolean);
|
|
78
111
|
if (tokens.length === 0) return false;
|
|
112
|
+
// A narrowing flag scopes the run wherever it appears — before the package
|
|
113
|
+
// manager's `--` as much as after it — so the probe runs over the whole
|
|
114
|
+
// token list rather than per-branch below.
|
|
115
|
+
if (hasNarrowingFlag(tokens)) return false;
|
|
79
116
|
|
|
80
117
|
if (tokens[0] === 'node') {
|
|
81
118
|
// `node --test` with no path argument walks the default test globs.
|
|
@@ -20,30 +20,13 @@
|
|
|
20
20
|
/** A `"//"` key that documents a pinned override, e.g. `overrides.js-yaml`. */
|
|
21
21
|
const OVERRIDE_NOTE_KEY = /^overrides\.(.+)$/;
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
* Extract every semver range that appears literally in a note's prose.
|
|
25
|
-
*
|
|
26
|
-
* Deliberately permissive about the surrounding words — a note is prose, and
|
|
27
|
-
* pinning its phrasing would make it unwritable. What matters is only that
|
|
28
|
-
* the range it quotes is the range in force.
|
|
29
|
-
*
|
|
30
|
-
* Not exported: it is an implementation detail of the audit below, and its
|
|
31
|
-
* behaviour is observable through that — a note quoting only bare versions
|
|
32
|
-
* yields no `stale-note`, a note quoting a mismatched range yields one.
|
|
33
|
-
*
|
|
34
|
-
* @param {string} text
|
|
35
|
-
* @returns {string[]}
|
|
36
|
-
*/
|
|
37
|
-
function quotedRanges(text) {
|
|
38
|
-
if (typeof text !== 'string') return [];
|
|
39
|
-
return [...text.matchAll(/[\^~]\d+\.\d+\.\d+/g)].map((m) => m[0]);
|
|
40
|
-
}
|
|
23
|
+
import { resolvePin, scoreNote } from './pinned-override-resolve.js';
|
|
41
24
|
|
|
42
25
|
/**
|
|
43
26
|
* Audit one package document's pinned-override notes.
|
|
44
27
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
28
|
+
* Findings per documented override, each naming the drift rather than just
|
|
29
|
+
* asserting a mismatch:
|
|
47
30
|
* - `lockstep` — `overrides.<name>` and `dependencies.<name>` disagree. The
|
|
48
31
|
* companion note declares they must not; a split silently gives the
|
|
49
32
|
* direct and transitive resolutions different floors.
|
|
@@ -51,50 +34,55 @@ function quotedRanges(text) {
|
|
|
51
34
|
* force, so its stated version is behind the pin it describes. A note
|
|
52
35
|
* quoting no range at all is not scored: prose that names no version
|
|
53
36
|
* cannot go stale.
|
|
37
|
+
* - `unsupported-shape` — the note's key resolves to something that is not a
|
|
38
|
+
* range (a nested override object, or a `$name` reference to a dependency
|
|
39
|
+
* that does not exist). Distinct from `orphan-note`: the pin is *there*,
|
|
40
|
+
* the note just does not name it, and telling an author to "delete the note
|
|
41
|
+
* or restore the pin" would be wrong advice.
|
|
54
42
|
*
|
|
55
43
|
* @param {{ '//'?: Record<string,string>, overrides?: Record<string,string>, dependencies?: Record<string,string> }} pkg
|
|
56
44
|
* @returns {{ findings: Array<{ kind: string, name: string, detail: string }>, checked: string[] }}
|
|
57
45
|
*/
|
|
58
46
|
export function auditPinnedOverrideNotes(pkg) {
|
|
59
|
-
const notes = pkg?.['//'] ?? {};
|
|
60
47
|
const overrides = pkg?.overrides ?? {};
|
|
61
48
|
const dependencies = pkg?.dependencies ?? {};
|
|
62
49
|
const findings = [];
|
|
63
50
|
const checked = [];
|
|
64
51
|
|
|
65
|
-
for (const
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
findings.push({
|
|
72
|
-
kind: 'orphan-note',
|
|
73
|
-
name,
|
|
74
|
-
detail: `"//"["${key}"] documents an override that no longer exists in the overrides block. Delete the note or restore the pin — a safety note for a pin nobody has is read as though the pin were still there.`,
|
|
75
|
-
});
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
checked.push(name);
|
|
79
|
-
|
|
80
|
-
const direct = dependencies[name];
|
|
81
|
-
if (typeof direct === 'string' && direct !== pinned) {
|
|
82
|
-
findings.push({
|
|
83
|
-
kind: 'lockstep',
|
|
84
|
-
name,
|
|
85
|
-
detail: `overrides.${name} is "${pinned}" but dependencies.${name} is "${direct}". The "//" note declares these move in lockstep; a split gives the direct and transitive resolutions different floors.`,
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const quoted = quotedRanges(text);
|
|
90
|
-
if (quoted.length > 0 && !quoted.includes(pinned)) {
|
|
91
|
-
findings.push({
|
|
92
|
-
kind: 'stale-note',
|
|
93
|
-
name,
|
|
94
|
-
detail: `"//"["${key}"] quotes ${quoted.map((q) => `"${q}"`).join(', ')} but the pin in force is "${pinned}". The note is what tells the next author whether a bump is safe, so it must state the version it is describing.`,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
52
|
+
for (const { key, name, text } of documentedOverrides(pkg)) {
|
|
53
|
+
const resolved = resolvePin({ overrides, dependencies, name });
|
|
54
|
+
if (isCheckable(resolved)) checked.push(name);
|
|
55
|
+
findings.push(
|
|
56
|
+
...scoreNote({ key, name, text, resolved, direct: dependencies[name] }),
|
|
57
|
+
);
|
|
97
58
|
}
|
|
98
59
|
|
|
99
60
|
return { findings, checked };
|
|
100
61
|
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The `"//"` entries that document an override, as `{ key, name, text }`.
|
|
65
|
+
* Every other note in the block — a peer-dependency rationale, say — is not
|
|
66
|
+
* this gate's business and is skipped rather than scored.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} pkg
|
|
69
|
+
* @returns {Array<{ key: string, name: string, text: unknown }>}
|
|
70
|
+
*/
|
|
71
|
+
function documentedOverrides(pkg) {
|
|
72
|
+
return Object.entries(pkg?.['//'] ?? {})
|
|
73
|
+
.map(([key, text]) => ({ key, text, match: OVERRIDE_NOTE_KEY.exec(key) }))
|
|
74
|
+
.filter((entry) => entry.match !== null)
|
|
75
|
+
.map(({ key, text, match }) => ({ key, text, name: match[1] }));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Was the note's override resolved to a range at all? Only then does the name
|
|
80
|
+
* belong in `checked` — the list is what tells a caller which pins this gate
|
|
81
|
+
* actually stands behind.
|
|
82
|
+
*
|
|
83
|
+
* @param {{ kind: string }} resolved
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
function isCheckable(resolved) {
|
|
87
|
+
return resolved.kind !== 'missing' && resolved.kind !== 'unsupported';
|
|
88
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pinned-override-resolve.js — resolve a documented override, and score its note.
|
|
3
|
+
*
|
|
4
|
+
* The reading half of `pinned-override-notes.js` next door: how an
|
|
5
|
+
* `overrides` entry resolves to the range it actually imposes, and which
|
|
6
|
+
* findings that range earns against the note describing it. The audit loop
|
|
7
|
+
* stays there; everything a single note is judged by lives here.
|
|
8
|
+
*
|
|
9
|
+
* Pure: no filesystem, no npm, no network. The caller supplies the parsed
|
|
10
|
+
* package document.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* npm's own reference syntax inside an `overrides` block: `"js-yaml": "$js-yaml"`
|
|
15
|
+
* means "whatever `dependencies.js-yaml` says". It is the sanctioned way to
|
|
16
|
+
* express the lockstep coupling these notes describe, so reading it as a
|
|
17
|
+
* literal range — which is what a bare string compare does — flagged the fix as
|
|
18
|
+
* the defect: `"$js-yaml" !== "^4.3.2"` produced a `lockstep` finding against a
|
|
19
|
+
* pair that npm guarantees can never split, and a `stale-note` against a note
|
|
20
|
+
* quoting the range actually in force.
|
|
21
|
+
*/
|
|
22
|
+
const OVERRIDE_REFERENCE = /^\$(.*)$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A semver **range**, not only a caret/tilde one. The old pattern required a
|
|
26
|
+
* `^` or `~` prefix, so a note describing an exact pin (`"1.2.3"`) quoted a
|
|
27
|
+
* version the matcher could not see: `quoted` came back empty, the staleness
|
|
28
|
+
* check short-circuits on an empty list, and the note was silently exempt from
|
|
29
|
+
* the guarantee it exists to provide.
|
|
30
|
+
*/
|
|
31
|
+
const SEMVER_RANGE = /(?:[\^~]|>=?|<=?|=)?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract every semver range that appears literally in a note's prose.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately permissive about the surrounding words — a note is prose, and
|
|
37
|
+
* pinning its phrasing would make it unwritable. What matters is only that
|
|
38
|
+
* the range it quotes is the range in force.
|
|
39
|
+
*
|
|
40
|
+
* Not exported: it is an implementation detail of the audit below, and its
|
|
41
|
+
* behaviour is observable through that — a note quoting only bare versions
|
|
42
|
+
* yields no `stale-note`, a note quoting a mismatched range yields one.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} text
|
|
45
|
+
* @returns {string[]}
|
|
46
|
+
*/
|
|
47
|
+
function quotedRanges(text) {
|
|
48
|
+
if (typeof text !== 'string') return [];
|
|
49
|
+
return [...text.matchAll(SEMVER_RANGE)].map((m) => m[0]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Look a dotted note key up in the `overrides` tree.
|
|
54
|
+
*
|
|
55
|
+
* An `overrides` value may be a nested object — `{"foo": {"bar": "1.2.3"}}`
|
|
56
|
+
* scopes the `bar` override to `foo`'s subtree — so `overrides.foo.bar` names a
|
|
57
|
+
* real pin two levels down. The exact key is tried first at every level,
|
|
58
|
+
* because package names legitimately contain dots (`lodash.merge`), and only
|
|
59
|
+
* then is the key split.
|
|
60
|
+
*
|
|
61
|
+
* @param {unknown} node
|
|
62
|
+
* @param {string} dottedName
|
|
63
|
+
* @returns {{ found: boolean, value?: unknown }}
|
|
64
|
+
*/
|
|
65
|
+
function lookupOverride(node, dottedName) {
|
|
66
|
+
if (!node || typeof node !== 'object') return { found: false };
|
|
67
|
+
if (Object.hasOwn(node, dottedName)) {
|
|
68
|
+
return { found: true, value: node[dottedName] };
|
|
69
|
+
}
|
|
70
|
+
const parts = dottedName.split('.');
|
|
71
|
+
for (let i = 1; i < parts.length; i += 1) {
|
|
72
|
+
const child = node[parts.slice(0, i).join('.')];
|
|
73
|
+
const hit = lookupOverride(child, parts.slice(i).join('.'));
|
|
74
|
+
if (hit.found) return hit;
|
|
75
|
+
}
|
|
76
|
+
return { found: false };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the range a documented override actually imposes.
|
|
81
|
+
*
|
|
82
|
+
* @param {object} params
|
|
83
|
+
* @returns {{ kind: 'missing'|'pinned'|'reference'|'unsupported', range?: string,
|
|
84
|
+
* raw?: unknown, referenced?: string, detail?: string }}
|
|
85
|
+
*/
|
|
86
|
+
export function resolvePin({ overrides, dependencies, name }) {
|
|
87
|
+
const hit = lookupOverride(overrides, name);
|
|
88
|
+
if (!hit.found) return { kind: 'missing' };
|
|
89
|
+
if (typeof hit.value !== 'string') {
|
|
90
|
+
return {
|
|
91
|
+
kind: 'unsupported',
|
|
92
|
+
raw: hit.value,
|
|
93
|
+
detail:
|
|
94
|
+
'resolves to a nested override object rather than a range. Key the note at the leaf ' +
|
|
95
|
+
'(e.g. "overrides.<parent>.<name>") so the range it describes is the one checked.',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const reference = OVERRIDE_REFERENCE.exec(hit.value);
|
|
99
|
+
return reference
|
|
100
|
+
? resolveReference({
|
|
101
|
+
raw: hit.value,
|
|
102
|
+
target: reference[1] || name,
|
|
103
|
+
dependencies,
|
|
104
|
+
})
|
|
105
|
+
: { kind: 'pinned', range: hit.value };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve npm's `"$name"` reference to the direct range it points at.
|
|
110
|
+
*
|
|
111
|
+
* @param {{ raw: string, target: string, dependencies: object }} params
|
|
112
|
+
* @returns {{ kind: 'reference'|'unsupported', range?: string, raw?: string,
|
|
113
|
+
* referenced: string, detail?: string }}
|
|
114
|
+
*/
|
|
115
|
+
function resolveReference({ raw, target, dependencies }) {
|
|
116
|
+
const direct = dependencies[target];
|
|
117
|
+
if (typeof direct !== 'string') {
|
|
118
|
+
return {
|
|
119
|
+
kind: 'unsupported',
|
|
120
|
+
raw,
|
|
121
|
+
referenced: target,
|
|
122
|
+
detail: `references "${raw}", but dependencies.${target} does not exist, so npm has nothing to resolve the override to.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
return { kind: 'reference', range: direct, referenced: target };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Score one documented override against its note. Split out of the loop above
|
|
130
|
+
* so each finding's rationale sits next to the condition that raises it.
|
|
131
|
+
*
|
|
132
|
+
* @param {{ key: string, name: string, text: unknown, resolved: object,
|
|
133
|
+
* direct: string|undefined }} params
|
|
134
|
+
* @returns {Array<{ kind: string, name: string, detail: string }>}
|
|
135
|
+
*/
|
|
136
|
+
export function scoreNote({ key, name, text, resolved, direct }) {
|
|
137
|
+
const refusal = refuseNote({ key, name, resolved });
|
|
138
|
+
if (refusal) return [refusal];
|
|
139
|
+
return [
|
|
140
|
+
...scoreLockstep({ name, resolved, direct }),
|
|
141
|
+
...scoreStaleness({ key, name, text, range: resolved.range }),
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The two ways a note names nothing checkable: the pin is gone, or the key
|
|
147
|
+
* resolves to something that is not a range. They are deliberately different
|
|
148
|
+
* findings — telling an author to "delete the note or restore the pin" when the
|
|
149
|
+
* pin is right there, one level down, is wrong advice.
|
|
150
|
+
*
|
|
151
|
+
* @param {{ key: string, name: string, resolved: object }} params
|
|
152
|
+
* @returns {{ kind: string, name: string, detail: string }|null}
|
|
153
|
+
*/
|
|
154
|
+
function refuseNote({ key, name, resolved }) {
|
|
155
|
+
if (resolved.kind === 'missing') {
|
|
156
|
+
return {
|
|
157
|
+
kind: 'orphan-note',
|
|
158
|
+
name,
|
|
159
|
+
detail: `"//"["${key}"] documents an override that no longer exists in the overrides block. Delete the note or restore the pin — a safety note for a pin nobody has is read as though the pin were still there.`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (resolved.kind === 'unsupported') {
|
|
163
|
+
return {
|
|
164
|
+
kind: 'unsupported-shape',
|
|
165
|
+
name,
|
|
166
|
+
detail: `"//"["${key}"] ${resolved.detail}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Does the override range still agree with the direct one? A `$name` override
|
|
174
|
+
* IS `dependencies[name]`, so npm cannot split the pair the note is warning
|
|
175
|
+
* about and there is nothing to compare.
|
|
176
|
+
*
|
|
177
|
+
* @param {{ name: string, resolved: object, direct: string|undefined }} params
|
|
178
|
+
* @returns {Array<object>}
|
|
179
|
+
*/
|
|
180
|
+
function scoreLockstep({ name, resolved, direct }) {
|
|
181
|
+
const split =
|
|
182
|
+
resolved.kind === 'pinned' &&
|
|
183
|
+
typeof direct === 'string' &&
|
|
184
|
+
direct !== resolved.range;
|
|
185
|
+
if (!split) return [];
|
|
186
|
+
return [
|
|
187
|
+
{
|
|
188
|
+
kind: 'lockstep',
|
|
189
|
+
name,
|
|
190
|
+
detail: `overrides.${name} is "${resolved.range}" but dependencies.${name} is "${direct}". The "//" note declares these move in lockstep; a split gives the direct and transitive resolutions different floors. npm's own "$${name}" reference is the way to make the split impossible.`,
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Does the note still state the range in force? A note quoting no range at all
|
|
197
|
+
* is not scored: prose that names no version cannot go stale.
|
|
198
|
+
*
|
|
199
|
+
* @param {{ key: string, name: string, text: unknown, range: string }} params
|
|
200
|
+
* @returns {Array<object>}
|
|
201
|
+
*/
|
|
202
|
+
function scoreStaleness({ key, name, text, range }) {
|
|
203
|
+
const quoted = quotedRanges(text);
|
|
204
|
+
if (quoted.length === 0 || quoted.includes(range)) return [];
|
|
205
|
+
return [
|
|
206
|
+
{
|
|
207
|
+
kind: 'stale-note',
|
|
208
|
+
name,
|
|
209
|
+
detail: `"//"["${key}"] quotes ${quoted.map((q) => `"${q}"`).join(', ')} but the range in force is "${range}". The note is what tells the next author whether a bump is safe, so it must state the version it is describing.`,
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
}
|
|
@@ -265,6 +265,14 @@ function toOrigin(value) {
|
|
|
265
265
|
* than mid-sweep. The resolved `SKILL.md` path is attached as
|
|
266
266
|
* `skillPath` so the harness reads the file the check actually found.
|
|
267
267
|
*
|
|
268
|
+
* The two failures get **different** messages (Story #5285). A well-formed id
|
|
269
|
+
* that resolves nowhere is a skill to author; a malformed one is an id to
|
|
270
|
+
* fix, and telling the operator to author `../../secrets/SKILL.md` would be
|
|
271
|
+
* advice they cannot take. A config validated against the shipped schema
|
|
272
|
+
* never reaches the malformed branch — the `pattern` rejects it first — but
|
|
273
|
+
* this resolver also runs over hand-assembled contracts in tests and over
|
|
274
|
+
* configs loaded past a degraded validator, so the branch is real.
|
|
275
|
+
*
|
|
268
276
|
* @param {object | undefined} seam
|
|
269
277
|
* @param {string} envName Environment name, for the error message.
|
|
270
278
|
* @param {{ repoRoot?: string }} options
|
|
@@ -276,6 +284,16 @@ function resolveSignInSeam(seam, envName, options) {
|
|
|
276
284
|
|
|
277
285
|
const repoRoot = options.repoRoot ?? PROJECT_ROOT;
|
|
278
286
|
const found = resolveSkillFile(repoRoot, seam.skill);
|
|
287
|
+
if (found?.reason === 'invalid-id') {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`qa: environment \`${envName}\` declares signInSeam.skill ` +
|
|
290
|
+
`\`${seam.skill}\`, which is not a well-formed skill id. ` +
|
|
291
|
+
'An id is two or more lowercase segments of letters, digits, `.`, ' +
|
|
292
|
+
'`_` or `-` joined by `/` — e.g. `stack/qa/acme-sso`. Traversals, ' +
|
|
293
|
+
'absolute paths, backslashes and uppercase segments are rejected ' +
|
|
294
|
+
'outright, so no skills root was searched. Correct the id.',
|
|
295
|
+
);
|
|
296
|
+
}
|
|
279
297
|
if (found === null) {
|
|
280
298
|
throw new Error(
|
|
281
299
|
`qa: environment \`${envName}\` declares signInSeam.skill ` +
|
|
@@ -102,6 +102,44 @@ function heartbeatIntervalFor(timeoutMs) {
|
|
|
102
102
|
return Math.max(MIN_HEARTBEAT_MS, derived);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Signals whose default disposition kills the process. A holder that takes
|
|
107
|
+
* one MUST drop its lockfile before it dies: the `'exit'` guard in
|
|
108
|
+
* {@link buildAcquired} never runs for a signal Node has not been asked to
|
|
109
|
+
* handle, so before Story #5278 a Ctrl-C during a full suite left a lockfile
|
|
110
|
+
* behind that every sibling then had to wait `timeoutMs` to break.
|
|
111
|
+
*/
|
|
112
|
+
const RELEASE_ON_SIGNALS = Object.freeze(['SIGINT', 'SIGTERM']);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Is `pid` a process this host is still running?
|
|
116
|
+
*
|
|
117
|
+
* `kill(pid, 0)` performs the permission and existence checks without
|
|
118
|
+
* delivering a signal. Three outcomes matter:
|
|
119
|
+
*
|
|
120
|
+
* - it returns → the process exists and is ours: **alive**;
|
|
121
|
+
* - `EPERM` → the process exists but belongs to another user: **alive**
|
|
122
|
+
* (an existence check that we are not allowed to complete is not
|
|
123
|
+
* evidence of death);
|
|
124
|
+
* - `ESRCH` → no such process: **dead**.
|
|
125
|
+
*
|
|
126
|
+
* A pid that cannot be read at all resolves to `null` — "unknown", which
|
|
127
|
+
* leaves the mtime heuristic in charge exactly as before.
|
|
128
|
+
*
|
|
129
|
+
* @param {number|null} pid
|
|
130
|
+
* @param {(pid: number, signal: number) => void} [killFn]
|
|
131
|
+
* @returns {boolean|null} `null` when the pid is unknown.
|
|
132
|
+
*/
|
|
133
|
+
function isHolderAlive(pid, killFn = process.kill.bind(process)) {
|
|
134
|
+
if (!Number.isInteger(pid) || pid <= 0) return null;
|
|
135
|
+
try {
|
|
136
|
+
killFn(pid, 0);
|
|
137
|
+
return true;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return err?.code === 'ESRCH' ? false : true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
105
143
|
/**
|
|
106
144
|
* Read the lockfile's *identity* — the tuple that distinguishes "the file I
|
|
107
145
|
* observed" from "a different file that now sits at the same path". `dev` +
|
|
@@ -265,7 +303,11 @@ function tryCreateLock(lockPath, ownerId, fsImpl = fs) {
|
|
|
265
303
|
* `timeoutMs`. `0` disables it.
|
|
266
304
|
* @param {Function} [opts.setIntervalFn] Timer seam for tests.
|
|
267
305
|
* @param {Function} [opts.clearIntervalFn] Timer seam for tests.
|
|
268
|
-
* @
|
|
306
|
+
* @param {Function} [opts.killFn] `process.kill` seam for the holder
|
|
307
|
+
* liveness probe (Story #5278).
|
|
308
|
+
* @param {object} [opts.processImpl] `process` seam for the
|
|
309
|
+
* release-on-signal handlers.
|
|
310
|
+
* @returns {{ acquired: true, release: () => void, refresh: () => boolean, ownerId: string }
|
|
269
311
|
* | { acquired: false, reason: 'contended' | 'error', detail?: string }}
|
|
270
312
|
*/
|
|
271
313
|
export function acquireSweepLock({
|
|
@@ -277,6 +319,8 @@ export function acquireSweepLock({
|
|
|
277
319
|
heartbeatMs,
|
|
278
320
|
setIntervalFn = setInterval,
|
|
279
321
|
clearIntervalFn = clearInterval,
|
|
322
|
+
killFn,
|
|
323
|
+
processImpl = process,
|
|
280
324
|
} = {}) {
|
|
281
325
|
if (typeof lockPath !== 'string' || lockPath.length === 0) {
|
|
282
326
|
return {
|
|
@@ -294,6 +338,8 @@ export function acquireSweepLock({
|
|
|
294
338
|
heartbeatMs: heartbeatMs ?? heartbeatIntervalFor(timeoutMs),
|
|
295
339
|
setIntervalFn,
|
|
296
340
|
clearIntervalFn,
|
|
341
|
+
killFn,
|
|
342
|
+
processImpl,
|
|
297
343
|
};
|
|
298
344
|
try {
|
|
299
345
|
if (
|
|
@@ -321,16 +367,56 @@ export function acquireSweepLock({
|
|
|
321
367
|
* @param {number} timeoutMs
|
|
322
368
|
* @returns {boolean}
|
|
323
369
|
*/
|
|
324
|
-
function tryStaleTakeover(
|
|
370
|
+
function tryStaleTakeover(
|
|
371
|
+
{ lockPath, ownerId, fsImpl, nowFn, killFn },
|
|
372
|
+
timeoutMs,
|
|
373
|
+
) {
|
|
325
374
|
const observed = readLockIdentity(lockPath, fsImpl);
|
|
326
375
|
if (observed === null) return false;
|
|
327
|
-
if (
|
|
376
|
+
if (
|
|
377
|
+
!isHolderStale({ lockPath, fsImpl, nowFn, killFn, observed, timeoutMs })
|
|
378
|
+
) {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
328
381
|
return (
|
|
329
382
|
breakStaleLock(lockPath, observed, fsImpl) &&
|
|
330
383
|
tryCreateLock(lockPath, ownerId, fsImpl)
|
|
331
384
|
);
|
|
332
385
|
}
|
|
333
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Is the observed holder dead enough to take over? Story #5278 adds the
|
|
389
|
+
* holder's **pid** as a way to answer "yes" *sooner* — never as a way to
|
|
390
|
+
* answer "no".
|
|
391
|
+
*
|
|
392
|
+
* - pid **dead** (`ESRCH`) → stale immediately, whatever the mtime says. A
|
|
393
|
+
* crashed or Ctrl-C'd holder no longer costs every sibling a full
|
|
394
|
+
* `timeoutMs` wait for a lockfile nobody is behind.
|
|
395
|
+
* - anything else (alive, or an unreadable pid) → the mtime heuristic
|
|
396
|
+
* decides, byte-for-byte the pre-#5278 rule.
|
|
397
|
+
*
|
|
398
|
+
* Deliberately one-directional. Letting a live pid *veto* the mtime rule
|
|
399
|
+
* would make a hung holder immortal and would break the reclaim contract the
|
|
400
|
+
* full-suite lock depends on; keeping the holder's mtime advancing while it
|
|
401
|
+
* works is the heartbeat's job (`refreshLockSync`), not this predicate's.
|
|
402
|
+
*
|
|
403
|
+
* @param {{ lockPath: string, fsImpl: object, nowFn: () => number, killFn?: Function, observed: {mtimeMs: number}, timeoutMs: number }} args
|
|
404
|
+
* @returns {boolean}
|
|
405
|
+
*/
|
|
406
|
+
function isHolderStale({
|
|
407
|
+
lockPath,
|
|
408
|
+
fsImpl,
|
|
409
|
+
nowFn,
|
|
410
|
+
killFn,
|
|
411
|
+
observed,
|
|
412
|
+
timeoutMs,
|
|
413
|
+
}) {
|
|
414
|
+
if (isHolderAlive(readLockHolderPid(lockPath, fsImpl), killFn) === false) {
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
return isLockStale(observed.mtimeMs, nowFn(), timeoutMs);
|
|
418
|
+
}
|
|
419
|
+
|
|
334
420
|
/**
|
|
335
421
|
* Unlink a stale lockfile — but only when it is still byte-for-byte the
|
|
336
422
|
* instance the caller observed. Returns `true` when this call removed that
|
|
@@ -362,10 +448,23 @@ function breakStaleLock(lockPath, observed, fsImpl) {
|
|
|
362
448
|
* line is no longer ours — after a steal the file belongs to someone else and
|
|
363
449
|
* bumping its mtime would keep *their* lock alive on our behalf.
|
|
364
450
|
*
|
|
451
|
+
* **The synchronous refresh entry point (Story #5278).** The interval-driven
|
|
452
|
+
* heartbeat below only fires when the holder's event loop gets a turn, which
|
|
453
|
+
* a `spawnSync` critical section never gives it. A caller on such a stack
|
|
454
|
+
* calls this directly — immediately before it blocks — so the lock it is
|
|
455
|
+
* about to sit on carries a current mtime rather than the one it was created
|
|
456
|
+
* with minutes earlier.
|
|
457
|
+
*
|
|
458
|
+
* @param {{ lockPath: string, ownerId: string, fsImpl?: object, nowFn?: () => number }} holder
|
|
365
459
|
* @returns {boolean} `true` when the refresh landed; `false` when the lock is
|
|
366
460
|
* no longer ours (the caller stops heartbeating).
|
|
367
461
|
*/
|
|
368
|
-
function
|
|
462
|
+
export function refreshLockSync({
|
|
463
|
+
lockPath,
|
|
464
|
+
ownerId,
|
|
465
|
+
fsImpl = fs,
|
|
466
|
+
nowFn = Date.now,
|
|
467
|
+
}) {
|
|
369
468
|
if (readLockOwner(lockPath, fsImpl) !== ownerId) return false;
|
|
370
469
|
try {
|
|
371
470
|
const stamp = new Date(nowFn());
|
|
@@ -398,7 +497,7 @@ function startHeartbeat(holder) {
|
|
|
398
497
|
}
|
|
399
498
|
};
|
|
400
499
|
timer = setIntervalFn(() => {
|
|
401
|
-
if (!
|
|
500
|
+
if (!refreshLockSync(holder)) stop();
|
|
402
501
|
}, heartbeatMs);
|
|
403
502
|
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
404
503
|
return stop;
|
|
@@ -424,22 +523,83 @@ function unlinkIfOwned(lockPath, ownerId, fsImpl) {
|
|
|
424
523
|
}
|
|
425
524
|
|
|
426
525
|
function buildAcquired(holder) {
|
|
427
|
-
const { lockPath, ownerId, fsImpl } = holder;
|
|
526
|
+
const { lockPath, ownerId, fsImpl, processImpl = process } = holder;
|
|
428
527
|
const stopHeartbeat = startHeartbeat(holder);
|
|
429
528
|
let released = false;
|
|
529
|
+
let detachSignals = () => {};
|
|
430
530
|
const release = () => {
|
|
431
531
|
if (released) return;
|
|
432
532
|
released = true;
|
|
433
533
|
stopHeartbeat();
|
|
534
|
+
detachSignals();
|
|
434
535
|
unlinkIfOwned(lockPath, ownerId, fsImpl);
|
|
435
536
|
};
|
|
436
537
|
// Belt-and-braces: process exit also clears the lockfile so a
|
|
437
538
|
// crashed run doesn't leave a stale-but-not-yet-old artifact behind.
|
|
438
539
|
const exitCleanup = () => release();
|
|
439
|
-
if (typeof
|
|
440
|
-
|
|
540
|
+
if (typeof processImpl?.once === 'function') {
|
|
541
|
+
processImpl.once('exit', exitCleanup);
|
|
441
542
|
}
|
|
442
|
-
|
|
543
|
+
detachSignals = attachSignalRelease(processImpl, release);
|
|
544
|
+
return {
|
|
545
|
+
acquired: true,
|
|
546
|
+
release,
|
|
547
|
+
refresh: () => refreshLockSync(holder),
|
|
548
|
+
ownerId,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Drop the lock on SIGINT / SIGTERM, then re-raise so the process still dies
|
|
554
|
+
* the way its caller asked it to (Story #5278).
|
|
555
|
+
*
|
|
556
|
+
* The `'exit'` guard above does not cover this: Node only runs `'exit'`
|
|
557
|
+
* handlers for a signal it has been asked to handle, so an unhandled Ctrl-C
|
|
558
|
+
* terminates the process with the lockfile still on disk. Registering here
|
|
559
|
+
* changes only *cleanup*, never the outcome — the handler removes itself and
|
|
560
|
+
* re-sends the same signal, so with no other listener the process dies under
|
|
561
|
+
* the default disposition and exits 128 + signum (130 for SIGINT), exactly as
|
|
562
|
+
* it did before.
|
|
563
|
+
*
|
|
564
|
+
* Returns a detach callback so a released holder stops intercepting signals
|
|
565
|
+
* it no longer has anything to clean up for.
|
|
566
|
+
*
|
|
567
|
+
* @param {object} processImpl
|
|
568
|
+
* @param {() => void} release
|
|
569
|
+
* @returns {() => void} detach
|
|
570
|
+
*/
|
|
571
|
+
function attachSignalRelease(processImpl, release) {
|
|
572
|
+
if (
|
|
573
|
+
typeof processImpl?.once !== 'function' ||
|
|
574
|
+
typeof processImpl?.off !== 'function' ||
|
|
575
|
+
typeof processImpl?.kill !== 'function'
|
|
576
|
+
) {
|
|
577
|
+
return () => {};
|
|
578
|
+
}
|
|
579
|
+
const handlers = RELEASE_ON_SIGNALS.map((signal) => {
|
|
580
|
+
const handler = () => {
|
|
581
|
+
release();
|
|
582
|
+
// `release()` has already detached every handler, so this re-raise
|
|
583
|
+
// reaches the default disposition (or another listener) rather than
|
|
584
|
+
// looping back into us.
|
|
585
|
+
try {
|
|
586
|
+
processImpl.kill(processImpl.pid, signal);
|
|
587
|
+
} catch {
|
|
588
|
+
// A process that cannot signal itself is already on its way out.
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
processImpl.once(signal, handler);
|
|
592
|
+
return [signal, handler];
|
|
593
|
+
});
|
|
594
|
+
return () => {
|
|
595
|
+
for (const [signal, handler] of handlers) {
|
|
596
|
+
try {
|
|
597
|
+
processImpl.off(signal, handler);
|
|
598
|
+
} catch {
|
|
599
|
+
// Best-effort: a seam may not implement removal.
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
};
|
|
443
603
|
}
|
|
444
604
|
|
|
445
605
|
const DEFAULT_WAIT_MS = 8_000;
|
|
@@ -501,6 +661,8 @@ export async function acquireLockWithWait({
|
|
|
501
661
|
heartbeatMs,
|
|
502
662
|
setIntervalFn = setInterval,
|
|
503
663
|
clearIntervalFn = clearInterval,
|
|
664
|
+
killFn,
|
|
665
|
+
processImpl,
|
|
504
666
|
} = {}) {
|
|
505
667
|
const deadline = nowFn() + Math.max(0, waitMs);
|
|
506
668
|
for (;;) {
|
|
@@ -513,6 +675,8 @@ export async function acquireLockWithWait({
|
|
|
513
675
|
heartbeatMs,
|
|
514
676
|
setIntervalFn,
|
|
515
677
|
clearIntervalFn,
|
|
678
|
+
killFn,
|
|
679
|
+
processImpl,
|
|
516
680
|
});
|
|
517
681
|
if (res.acquired) return res;
|
|
518
682
|
// A hard error will not resolve by retrying — surface it immediately.
|