start-command 0.39.2 → 0.41.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/CHANGELOG.md +26 -0
- package/eslint.config.mjs +32 -0
- package/package.json +9 -10
- package/src/bin/cli.js +18 -6
- package/src/lib/args-parser.js +33 -0
- package/src/lib/command-stream.js +19 -5
- package/src/lib/execution-chain.js +2 -2
- package/src/lib/execution-store.js +62 -44
- package/src/lib/formal-ai-cli.js +237 -0
- package/src/lib/formal-ai-client.js +166 -0
- package/src/lib/formal-ai-policy.js +501 -0
- package/src/lib/formal-ai-runtime.js +304 -0
- package/src/lib/formal-ai-tool.js +182 -0
- package/src/lib/formal-ai.js +329 -0
- package/src/lib/log-sanitizer.js +16 -137
- package/src/lib/usage.js +3 -1
- package/test/ci-workflow-invariants.js +18 -0
- package/test/command-stream-log.js +85 -0
- package/test/dependency-freshness.mjs +552 -0
- package/test/execution-store-clink.js +152 -0
- package/test/execution-store.js +49 -5
- package/test/fixtures/executions-lino-objects-codec-0.4.0.lino +1 -0
- package/test/fixtures/executions-lino-objects-codec-0.9.3.lino +26 -0
- package/test/fixtures/formal-ai/fake-server.cjs +17 -0
- package/test/fixtures/formal-ai/helpers.cjs +189 -0
- package/test/fixtures/formal-ai/receipts.json +776 -0
- package/test/fixtures/formal-ai/replay.cjs +144 -0
- package/test/javascript-first-ci.js +29 -0
- package/test/log-sanitizer.js +116 -47
- package/test/regression-187.js +4 -0
- package/test/regression-224.js +4 -2
- package/test/regression-231.js +20 -0
- package/test/regression-255-cli.js +294 -0
- package/test/regression-255-e2e.js +90 -0
- package/test/regression-255-loop.js +475 -0
- package/test/regression-255-policy.js +436 -0
- package/test/release-git.mjs +2 -1
- package/test/shared-golden.js +3 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #253: the dependency freshness gate. The registry is mocked, so these
|
|
3
|
+
* tests need no network.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it } from 'bun:test';
|
|
6
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
bunLockVersions,
|
|
12
|
+
cargoDependencies,
|
|
13
|
+
cargoLockVersions,
|
|
14
|
+
checkDependencies,
|
|
15
|
+
collectAll,
|
|
16
|
+
createRegistry,
|
|
17
|
+
evaluate,
|
|
18
|
+
lineDependencies,
|
|
19
|
+
lowerBound,
|
|
20
|
+
newestRunners,
|
|
21
|
+
newestTag,
|
|
22
|
+
npmLockVersions,
|
|
23
|
+
parseStable,
|
|
24
|
+
versionChanges,
|
|
25
|
+
} from '../../scripts/check-dependency-freshness.mjs';
|
|
26
|
+
|
|
27
|
+
const ISSUE = 'https://github.com/owner/repo/issues/7';
|
|
28
|
+
const SHA_A = 'a'.repeat(40);
|
|
29
|
+
const SHA_B = 'b'.repeat(40);
|
|
30
|
+
|
|
31
|
+
/** A registry whose answers come from plain objects. */
|
|
32
|
+
function mockRegistry({
|
|
33
|
+
npm = {},
|
|
34
|
+
crate = {},
|
|
35
|
+
pypi = {},
|
|
36
|
+
tags = {},
|
|
37
|
+
branches = {},
|
|
38
|
+
heads = {},
|
|
39
|
+
nodeLts = '24',
|
|
40
|
+
dotnetLts = '10.0',
|
|
41
|
+
runners = {
|
|
42
|
+
ubuntu: 'ubuntu-26.04',
|
|
43
|
+
macos: 'macos-26',
|
|
44
|
+
windows: 'windows-2025',
|
|
45
|
+
},
|
|
46
|
+
issues = {},
|
|
47
|
+
} = {}) {
|
|
48
|
+
const lookup = (table, key) => {
|
|
49
|
+
if (!(key in table)) {
|
|
50
|
+
throw new Error(`unexpected lookup ${key}`);
|
|
51
|
+
}
|
|
52
|
+
return table[key];
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
npm: async (name) => lookup(npm, name),
|
|
56
|
+
crate: async (name) => lookup(crate, name),
|
|
57
|
+
pypi: async (name) => lookup(pypi, name),
|
|
58
|
+
tag: async (repo) => newestTag(lookup(tags, repo)),
|
|
59
|
+
branch: async (repo, branch) => lookup(branches, `${repo}#${branch}`),
|
|
60
|
+
head: async (url) => lookup(heads, url),
|
|
61
|
+
nodeLts: async () => nodeLts,
|
|
62
|
+
dotnetLts: async () => dotnetLts,
|
|
63
|
+
runners: async () => runners,
|
|
64
|
+
issue: async (url) => lookup(issues, url),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('versions', () => {
|
|
69
|
+
it('parses stable versions only', () => {
|
|
70
|
+
expect(parseStable('v1.2.3')).toEqual([1, 2, 3]);
|
|
71
|
+
expect(parseStable('1.2.3-beta.1')).toBeNull();
|
|
72
|
+
expect(parseStable('1.2')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('takes the lower bound of a requirement', () => {
|
|
76
|
+
expect(lowerBound('^1.2.3')).toBe('1.2.3');
|
|
77
|
+
expect(lowerBound('~0.4')).toBe('0.4');
|
|
78
|
+
expect(lowerBound('>= 2.0.0')).toBe('2.0.0');
|
|
79
|
+
expect(lowerBound('1.0.229')).toBe('1.0.229');
|
|
80
|
+
expect(lowerBound('1.2.3+build')).toBe('1.2.3');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('picks the newest stable v-tag and its peeled commit', () => {
|
|
84
|
+
const newest = newestTag({
|
|
85
|
+
'refs/tags/v1.9.0': SHA_A,
|
|
86
|
+
'refs/tags/v1.10.0': SHA_B,
|
|
87
|
+
'refs/tags/v2.0.0-rc.1': SHA_A,
|
|
88
|
+
'refs/tags/v1': SHA_A,
|
|
89
|
+
'refs/heads/main': SHA_A,
|
|
90
|
+
});
|
|
91
|
+
expect(newest.tag).toBe('v1.10.0');
|
|
92
|
+
expect(newest.sha).toBe(SHA_B);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('lists version moves between two lockfile maps', () => {
|
|
96
|
+
expect(
|
|
97
|
+
versionChanges(
|
|
98
|
+
{ 'node_modules/a': '1.0.0', 'node_modules/b': '1.0.0' },
|
|
99
|
+
{ 'node_modules/a': '1.1.0', 'node_modules/c': '2.0.0' }
|
|
100
|
+
)
|
|
101
|
+
).toEqual([
|
|
102
|
+
'node_modules/a: 1.0.0 -> 1.1.0',
|
|
103
|
+
'node_modules/b: 1.0.0 -> absent',
|
|
104
|
+
'node_modules/c: absent -> 2.0.0',
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('lockfiles', () => {
|
|
110
|
+
it('maps bun.lock keys to package-lock paths', () => {
|
|
111
|
+
const lock = `{
|
|
112
|
+
"lockfileVersion": 1,
|
|
113
|
+
"packages": {
|
|
114
|
+
"a": ["a@1.0.0", "", {}, "sha512-x"],
|
|
115
|
+
"@s/b": ["@s/b@2.0.0", "", {}, "sha512-y"],
|
|
116
|
+
"a/@s/b": ["@s/b@1.5.0", "", {}, "sha512-z"],
|
|
117
|
+
"@s/b/c": ["c@3.0.0", "", {}, "sha512-w"],
|
|
118
|
+
},
|
|
119
|
+
}`;
|
|
120
|
+
expect(bunLockVersions(lock)).toEqual({
|
|
121
|
+
'node_modules/a': '1.0.0',
|
|
122
|
+
'node_modules/@s/b': '2.0.0',
|
|
123
|
+
'node_modules/a/node_modules/@s/b': '1.5.0',
|
|
124
|
+
'node_modules/@s/b/node_modules/c': '3.0.0',
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('reads package-lock.json packages and skips the root and links', () => {
|
|
129
|
+
const lock = JSON.stringify({
|
|
130
|
+
packages: {
|
|
131
|
+
'': { version: '0.0.1' },
|
|
132
|
+
'node_modules/a': { version: '1.0.0' },
|
|
133
|
+
'node_modules/l': { link: true, resolved: '../l' },
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
expect(npmLockVersions(lock)).toEqual({ 'node_modules/a': '1.0.0' });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('collects every version of a crate in Cargo.lock', () => {
|
|
140
|
+
const lock =
|
|
141
|
+
'version = 4\n\n[[package]]\nname = "a"\nversion = "1.0.0"\n\n[[package]]\nname = "a"\nversion = "2.0.0"\n';
|
|
142
|
+
expect(cargoLockVersions(lock)).toEqual({ a: ['1.0.0', '2.0.0'] });
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('Cargo.toml', () => {
|
|
147
|
+
it('reads every dependency table form and line blockers', () => {
|
|
148
|
+
const toml = [
|
|
149
|
+
'[package]',
|
|
150
|
+
'version = "9.9.9"',
|
|
151
|
+
'[dependencies]',
|
|
152
|
+
'a = "1.2.3"',
|
|
153
|
+
`b = { version = "0.4", features = ["x"] } # ${ISSUE}`,
|
|
154
|
+
'local = { path = "../local" }',
|
|
155
|
+
'renamed = { package = "real", version = "2.0.0" }',
|
|
156
|
+
"[target.'cfg(unix)'.dependencies]",
|
|
157
|
+
'libc = "0.2.191"',
|
|
158
|
+
'[dev-dependencies]',
|
|
159
|
+
'tempfile = "3"',
|
|
160
|
+
'[dependencies.table]',
|
|
161
|
+
'version = "5.0.0"',
|
|
162
|
+
'[features]',
|
|
163
|
+
'default = []',
|
|
164
|
+
].join('\n');
|
|
165
|
+
expect(
|
|
166
|
+
cargoDependencies(toml, 'Cargo.toml').map((d) => [
|
|
167
|
+
d.name,
|
|
168
|
+
d.declared,
|
|
169
|
+
d.line,
|
|
170
|
+
d.blocker,
|
|
171
|
+
])
|
|
172
|
+
).toEqual([
|
|
173
|
+
['a', '1.2.3', 4, undefined],
|
|
174
|
+
['b', '0.4', 5, ISSUE],
|
|
175
|
+
['real', '2.0.0', 7, undefined],
|
|
176
|
+
['libc', '0.2.191', 9, undefined],
|
|
177
|
+
['tempfile', '3', 11, undefined],
|
|
178
|
+
['table', '5.0.0', 12, undefined],
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('workflow lines', () => {
|
|
184
|
+
const kinds = (row, env) =>
|
|
185
|
+
lineDependencies(row, 'w.yml', 3, env).map((d) => [
|
|
186
|
+
d.kind,
|
|
187
|
+
d.name,
|
|
188
|
+
d.declared,
|
|
189
|
+
]);
|
|
190
|
+
|
|
191
|
+
it('recognises actions, tags and commit pins', () => {
|
|
192
|
+
expect(kinds(' - uses: actions/checkout@v7')).toEqual([
|
|
193
|
+
['action', 'actions/checkout', 'v7'],
|
|
194
|
+
]);
|
|
195
|
+
const [pin] = lineDependencies(
|
|
196
|
+
` - uses: o/r@${SHA_A} # v1.2.3`,
|
|
197
|
+
'w.yml',
|
|
198
|
+
3
|
|
199
|
+
);
|
|
200
|
+
expect(pin.pinnedTag).toBe('v1.2.3');
|
|
201
|
+
const [branch] = lineDependencies(
|
|
202
|
+
` - uses: o/r@${SHA_A} # master @ 2026-10-24`,
|
|
203
|
+
'w.yml',
|
|
204
|
+
3
|
|
205
|
+
);
|
|
206
|
+
expect(branch.pinnedBranch).toBe('master');
|
|
207
|
+
expect(kinds(' - uses: ./.github/actions/local')).toEqual([]);
|
|
208
|
+
expect(kinds(' uses: ./.github/workflows/rust.yml')).toEqual([]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('recognises CI tools and runtimes', () => {
|
|
212
|
+
expect(kinds('run: npx --yes -p a@1.0.0 -p @s/b@2.0.0 a')).toEqual([
|
|
213
|
+
['npm', 'a', '1.0.0'],
|
|
214
|
+
['npm', '@s/b', '2.0.0'],
|
|
215
|
+
]);
|
|
216
|
+
expect(kinds('python -m pip install --user pyflakes==4.0.3')).toEqual([
|
|
217
|
+
['pypi', 'pyflakes', '4.0.3'],
|
|
218
|
+
]);
|
|
219
|
+
expect(kinds(' tool: cargo-audit@0.22.2')).toEqual([
|
|
220
|
+
['crate', 'cargo-audit', '0.22.2'],
|
|
221
|
+
]);
|
|
222
|
+
expect(
|
|
223
|
+
kinds(
|
|
224
|
+
' curl -L https://github.com/rhysd/actionlint/releases/download/v1.7.12/x.tgz'
|
|
225
|
+
)
|
|
226
|
+
).toEqual([['release', 'rhysd/actionlint', 'v1.7.12']]);
|
|
227
|
+
expect(kinds(" node-version: '24.x'")).toEqual([
|
|
228
|
+
['node', 'node', '24.x'],
|
|
229
|
+
]);
|
|
230
|
+
expect(kinds(" dotnet-version: '10.0.x'")).toEqual([
|
|
231
|
+
['dotnet', 'dotnet', '10.0.x'],
|
|
232
|
+
]);
|
|
233
|
+
expect(kinds(' runs-on: ubuntu-26.04')).toEqual([
|
|
234
|
+
['runner', 'ubuntu-26.04', 'ubuntu-26.04'],
|
|
235
|
+
]);
|
|
236
|
+
expect(kinds(' os: [ubuntu-26.04, macos-26, windows-2025]')).toEqual(
|
|
237
|
+
[
|
|
238
|
+
['runner', 'ubuntu-26.04', 'ubuntu-26.04'],
|
|
239
|
+
['runner', 'macos-26', 'macos-26'],
|
|
240
|
+
['runner', 'windows-2025', 'windows-2025'],
|
|
241
|
+
]
|
|
242
|
+
);
|
|
243
|
+
expect(kinds(' # mentions ubuntu-22.04 in prose')).toEqual([]);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('resolves cargo install --version from an env entry', () => {
|
|
247
|
+
const [dep] = lineDependencies(
|
|
248
|
+
'run: cargo install cargo-tarpaulin --version "${TARPAULIN_VERSION}" --locked',
|
|
249
|
+
'w.yml',
|
|
250
|
+
9,
|
|
251
|
+
{ TARPAULIN_VERSION: { declared: '0.37.5', line: 4, blocker: ISSUE } }
|
|
252
|
+
);
|
|
253
|
+
expect(dep).toMatchObject({
|
|
254
|
+
kind: 'crate',
|
|
255
|
+
name: 'cargo-tarpaulin',
|
|
256
|
+
declared: '0.37.5',
|
|
257
|
+
line: 4,
|
|
258
|
+
blocker: ISSUE,
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe('runner images', () => {
|
|
264
|
+
it('picks the newest generally available label of each family', () => {
|
|
265
|
+
const readme = [
|
|
266
|
+
'| Image | YAML Label | Included Software |',
|
|
267
|
+
'| --- | --- | --- |',
|
|
268
|
+
'| Ubuntu 26.04  | `ubuntu-26.04-arm` | x |',
|
|
269
|
+
'| Ubuntu 26.04 | `ubuntu-latest` or `ubuntu-26.04` | x |',
|
|
270
|
+
'| Ubuntu 24.04 | `ubuntu-24.04` | x |',
|
|
271
|
+
'| macOS 27  | `macos-27` | x |',
|
|
272
|
+
'| macOS 26 | `macos-latest` or `macos-26` | x |',
|
|
273
|
+
'| Windows Server 2025 | `windows-latest` or `windows-2025` | x |',
|
|
274
|
+
'| Windows Server 2022  | `windows-2022` | x |',
|
|
275
|
+
].join('\n');
|
|
276
|
+
expect(newestRunners(readme)).toEqual({
|
|
277
|
+
ubuntu: 'ubuntu-26.04',
|
|
278
|
+
macos: 'macos-26',
|
|
279
|
+
windows: 'windows-2025',
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('evaluate', () => {
|
|
285
|
+
const registry = mockRegistry({
|
|
286
|
+
npm: { a: '2.0.0', bun: '1.4.3' },
|
|
287
|
+
crate: { c: '1.0.229' },
|
|
288
|
+
pypi: { p: '4.0.3' },
|
|
289
|
+
tags: {
|
|
290
|
+
'o/r': { 'refs/tags/v7.0.1': SHA_A, 'refs/tags/v6.0.0': SHA_B },
|
|
291
|
+
},
|
|
292
|
+
branches: { 'o/t#master': SHA_A },
|
|
293
|
+
heads: { 'https://github.com/o/m': SHA_A },
|
|
294
|
+
});
|
|
295
|
+
const problems = async (dep) => (await evaluate(dep, registry)).problems;
|
|
296
|
+
|
|
297
|
+
it('requires the declared floor and every locked version to be latest', async () => {
|
|
298
|
+
expect(
|
|
299
|
+
await problems({
|
|
300
|
+
kind: 'npm',
|
|
301
|
+
name: 'a',
|
|
302
|
+
declared: '^2.0.0',
|
|
303
|
+
locked: [{ file: 'l', version: '2.0.0' }],
|
|
304
|
+
})
|
|
305
|
+
).toEqual([]);
|
|
306
|
+
expect(
|
|
307
|
+
await problems({
|
|
308
|
+
kind: 'npm',
|
|
309
|
+
name: 'a',
|
|
310
|
+
declared: '^1.0.0',
|
|
311
|
+
locked: [{ file: 'l', version: '1.9.0' }, { file: 'm' }],
|
|
312
|
+
})
|
|
313
|
+
).toEqual(['declared ^1.0.0', 'l resolves 1.9.0', 'm resolves nothing']);
|
|
314
|
+
expect(await problems({ kind: 'crate', name: 'c', declared: '1' })).toEqual(
|
|
315
|
+
['declared 1']
|
|
316
|
+
);
|
|
317
|
+
expect(
|
|
318
|
+
await problems({ kind: 'pypi', name: 'p', declared: '4.0.3' })
|
|
319
|
+
).toEqual([]);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('checks action majors, exact tags and commit pins', async () => {
|
|
323
|
+
const action = (declared, extra = {}) =>
|
|
324
|
+
problems({ kind: 'action', name: 'o/r', declared, ...extra });
|
|
325
|
+
expect(await action('v7')).toEqual([]);
|
|
326
|
+
expect(await action('v6')).toEqual(['uses v6']);
|
|
327
|
+
expect(await action('v7.0.1')).toEqual([]);
|
|
328
|
+
expect(await action('v7.0.0')).toEqual(['uses v7.0.0']);
|
|
329
|
+
expect(await action(SHA_A, { pinnedTag: 'v7.0.1' })).toEqual([]);
|
|
330
|
+
expect(await action(SHA_B, { pinnedTag: 'v7.0.1' })).toEqual([
|
|
331
|
+
`pinned ${SHA_B}, not the v7.0.1 commit`,
|
|
332
|
+
]);
|
|
333
|
+
expect(await action(SHA_B, { pinnedTag: 'v6.0.0' })).toEqual([
|
|
334
|
+
'pinned v6.0.0',
|
|
335
|
+
]);
|
|
336
|
+
expect(await action(SHA_A)).toEqual([
|
|
337
|
+
'commit pin needs a `# vX.Y.Z` comment naming its release',
|
|
338
|
+
]);
|
|
339
|
+
expect(
|
|
340
|
+
await problems({
|
|
341
|
+
kind: 'action',
|
|
342
|
+
name: 'o/t',
|
|
343
|
+
declared: SHA_B,
|
|
344
|
+
pinnedBranch: 'master',
|
|
345
|
+
})
|
|
346
|
+
).toEqual([`pinned ${SHA_B}`]);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('checks runtimes, runners, releases and git revisions', async () => {
|
|
350
|
+
expect(
|
|
351
|
+
await problems({ kind: 'node', name: 'node', declared: '24.x' })
|
|
352
|
+
).toEqual([]);
|
|
353
|
+
expect(
|
|
354
|
+
await problems({ kind: 'node', name: 'node', declared: '22' })
|
|
355
|
+
).toEqual(['declared 22']);
|
|
356
|
+
expect(
|
|
357
|
+
await problems({ kind: 'bun', name: 'bun', declared: 'latest' })
|
|
358
|
+
).toEqual(['declared latest']);
|
|
359
|
+
expect(
|
|
360
|
+
await problems({ kind: 'dotnet', name: 'dotnet', declared: '8.0.x' })
|
|
361
|
+
).toEqual(['declared 8.0.x']);
|
|
362
|
+
expect(
|
|
363
|
+
await problems({ kind: 'dotnet', name: 'dotnet', declared: '10.0.x' })
|
|
364
|
+
).toEqual([]);
|
|
365
|
+
expect(
|
|
366
|
+
await problems({
|
|
367
|
+
kind: 'runner',
|
|
368
|
+
name: 'ubuntu-24.04',
|
|
369
|
+
declared: 'ubuntu-24.04',
|
|
370
|
+
})
|
|
371
|
+
).toEqual(['uses ubuntu-24.04']);
|
|
372
|
+
expect(
|
|
373
|
+
await problems({ kind: 'release', name: 'o/r', declared: 'v7.0.1' })
|
|
374
|
+
).toEqual([]);
|
|
375
|
+
expect(
|
|
376
|
+
await problems({
|
|
377
|
+
kind: 'git',
|
|
378
|
+
name: 'https://github.com/o/m',
|
|
379
|
+
declared: SHA_B,
|
|
380
|
+
})
|
|
381
|
+
).toEqual([`revision ${SHA_B}`]);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
describe('checkDependencies', () => {
|
|
386
|
+
const stale = (blocker) => ({
|
|
387
|
+
kind: 'npm',
|
|
388
|
+
name: 'a',
|
|
389
|
+
declared: '^1.0.0',
|
|
390
|
+
file: 'js/package.json',
|
|
391
|
+
line: 5,
|
|
392
|
+
blocker,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('fails a stale dependency with its location and the latest version', async () => {
|
|
396
|
+
const report = await checkDependencies(
|
|
397
|
+
[stale()],
|
|
398
|
+
mockRegistry({ npm: { a: '2.0.0' } })
|
|
399
|
+
);
|
|
400
|
+
expect(report.failures).toEqual([
|
|
401
|
+
'js/package.json:5 npm a: declared ^1.0.0; latest is 2.0.0',
|
|
402
|
+
]);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('exempts a stale dependency only while its blocker issue is open', async () => {
|
|
406
|
+
const open = await checkDependencies(
|
|
407
|
+
[stale(ISSUE)],
|
|
408
|
+
mockRegistry({
|
|
409
|
+
npm: { a: '2.0.0' },
|
|
410
|
+
issues: { [ISSUE]: { state: 'open' } },
|
|
411
|
+
})
|
|
412
|
+
);
|
|
413
|
+
expect(open.failures).toEqual([]);
|
|
414
|
+
expect(open.exemptions[0]).toContain(`blocked by ${ISSUE}`);
|
|
415
|
+
for (const issue of [
|
|
416
|
+
{ state: 'closed' },
|
|
417
|
+
{ state: 'open', pull_request: {} },
|
|
418
|
+
]) {
|
|
419
|
+
const report = await checkDependencies(
|
|
420
|
+
[stale(ISSUE)],
|
|
421
|
+
mockRegistry({ npm: { a: '2.0.0' }, issues: { [ISSUE]: issue } })
|
|
422
|
+
);
|
|
423
|
+
expect(report.failures[0]).toContain('is not an open issue');
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('reports in declaration order, not lookup completion order', async () => {
|
|
428
|
+
const deps = ['slow', 'fast'].map((name, index) => ({
|
|
429
|
+
...stale(),
|
|
430
|
+
name,
|
|
431
|
+
line: index + 1,
|
|
432
|
+
}));
|
|
433
|
+
const delays = { slow: 30, fast: 0 };
|
|
434
|
+
const registry = {
|
|
435
|
+
npm: (name) =>
|
|
436
|
+
new Promise((resolve) => setTimeout(resolve, delays[name], '2.0.0')),
|
|
437
|
+
};
|
|
438
|
+
const report = await checkDependencies(deps, registry);
|
|
439
|
+
expect(report.failures.map((failure) => failure.split(' ')[0])).toEqual([
|
|
440
|
+
'js/package.json:1',
|
|
441
|
+
'js/package.json:2',
|
|
442
|
+
]);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('fails a dependency it cannot verify', async () => {
|
|
446
|
+
const report = await checkDependencies([stale()], mockRegistry());
|
|
447
|
+
expect(report.failures[0]).toContain('cannot verify: unexpected lookup a');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('caches registry lookups', async () => {
|
|
451
|
+
const urls = [];
|
|
452
|
+
const registry = createRegistry({
|
|
453
|
+
getText: async (url) => {
|
|
454
|
+
urls.push(url);
|
|
455
|
+
return JSON.stringify({ version: '2.0.0' });
|
|
456
|
+
},
|
|
457
|
+
getRefs: async () => ({}),
|
|
458
|
+
});
|
|
459
|
+
await checkDependencies(
|
|
460
|
+
[
|
|
461
|
+
{ ...stale(), declared: '^2.0.0' },
|
|
462
|
+
{ ...stale(), declared: '^2.0.0' },
|
|
463
|
+
],
|
|
464
|
+
registry
|
|
465
|
+
);
|
|
466
|
+
expect(urls).toEqual(['https://registry.npmjs.org/a/latest']);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe('collectAll', () => {
|
|
471
|
+
it('collects manifests, lockfiles, workflows, scripts and the translator', () => {
|
|
472
|
+
const root = mkdtempSync(join(tmpdir(), 'freshness-'));
|
|
473
|
+
try {
|
|
474
|
+
for (const dir of [
|
|
475
|
+
'js',
|
|
476
|
+
'rust',
|
|
477
|
+
'.github/workflows',
|
|
478
|
+
'scripts',
|
|
479
|
+
'parity',
|
|
480
|
+
]) {
|
|
481
|
+
mkdirSync(join(root, dir), { recursive: true });
|
|
482
|
+
}
|
|
483
|
+
writeFileSync(
|
|
484
|
+
join(root, 'js/package.json'),
|
|
485
|
+
JSON.stringify(
|
|
486
|
+
{ dependencies: { a: '^1.0.0' }, overrides: { b: '^2.0.0' } },
|
|
487
|
+
null,
|
|
488
|
+
2
|
|
489
|
+
)
|
|
490
|
+
);
|
|
491
|
+
writeFileSync(
|
|
492
|
+
join(root, 'js/package-lock.json'),
|
|
493
|
+
JSON.stringify({ packages: { 'node_modules/a': { version: '1.0.0' } } })
|
|
494
|
+
);
|
|
495
|
+
writeFileSync(
|
|
496
|
+
join(root, '.github/dependency-blockers.json'),
|
|
497
|
+
JSON.stringify({ 'npm:b': `waiting for ${ISSUE}` })
|
|
498
|
+
);
|
|
499
|
+
writeFileSync(
|
|
500
|
+
join(root, 'rust/Cargo.toml'),
|
|
501
|
+
'[dependencies]\nserde = "1.0.229"\n'
|
|
502
|
+
);
|
|
503
|
+
writeFileSync(
|
|
504
|
+
join(root, '.github/workflows/ci.yml'),
|
|
505
|
+
'jobs:\n a:\n runs-on: ubuntu-26.04\n steps:\n - uses: actions/checkout@v7\n'
|
|
506
|
+
);
|
|
507
|
+
writeFileSync(
|
|
508
|
+
join(root, 'scripts/install.sh'),
|
|
509
|
+
'curl https://github.com/o/r/releases/download/v1.0.0/x\n'
|
|
510
|
+
);
|
|
511
|
+
writeFileSync(
|
|
512
|
+
join(root, 'parity/translation.json'),
|
|
513
|
+
JSON.stringify({
|
|
514
|
+
repository: 'https://github.com/o/m',
|
|
515
|
+
revision: SHA_A,
|
|
516
|
+
})
|
|
517
|
+
);
|
|
518
|
+
const deps = collectAll(root).map(
|
|
519
|
+
(d) => `${d.kind}:${d.name}@${d.file}:${d.line}`
|
|
520
|
+
);
|
|
521
|
+
expect(deps).toEqual([
|
|
522
|
+
'npm:a@js/package.json:3',
|
|
523
|
+
'npm:b@js/package.json:6',
|
|
524
|
+
'crate:serde@rust/Cargo.toml:2',
|
|
525
|
+
'runner:ubuntu-26.04@.github/workflows/ci.yml:3',
|
|
526
|
+
'action:actions/checkout@.github/workflows/ci.yml:5',
|
|
527
|
+
'release:o/r@scripts/install.sh:1',
|
|
528
|
+
'git:https://github.com/o/m@parity/translation.json:1',
|
|
529
|
+
]);
|
|
530
|
+
const [a, b] = collectAll(root);
|
|
531
|
+
expect(a.locked).toEqual([
|
|
532
|
+
{ file: 'js/package-lock.json', version: '1.0.0' },
|
|
533
|
+
]);
|
|
534
|
+
expect(b.blocker).toBe(ISSUE);
|
|
535
|
+
} finally {
|
|
536
|
+
rmSync(root, { recursive: true, force: true });
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('parses this repository without errors', () => {
|
|
541
|
+
const deps = collectAll();
|
|
542
|
+
const names = new Set(deps.map((d) => d.name));
|
|
543
|
+
for (const name of [
|
|
544
|
+
'@link-foundation/sensitive-data-sanitizer',
|
|
545
|
+
'command-stream',
|
|
546
|
+
'lino-objects-codec',
|
|
547
|
+
'actions/checkout',
|
|
548
|
+
]) {
|
|
549
|
+
expect(names.has(name)).toBe(true);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #253: the links database was never written. clink 4.x rejected every
|
|
3
|
+
* query the store built (unbalanced parentheses, `\"` escapes, references to
|
|
4
|
+
* links it would not create), and the failures were only logged. These tests
|
|
5
|
+
* pin the query shape and, where clink is installed, run it for real.
|
|
6
|
+
*/
|
|
7
|
+
const { describe, it, expect } = require('bun:test');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
ExecutionStore,
|
|
14
|
+
ExecutionRecord,
|
|
15
|
+
isClinkInstalled,
|
|
16
|
+
clinkReference,
|
|
17
|
+
} = require('../src/lib/execution-store');
|
|
18
|
+
|
|
19
|
+
const UUID = '4df9d19e-da50-4a7b-9e97-2c4f15b35a88';
|
|
20
|
+
|
|
21
|
+
function withStore(options, fn) {
|
|
22
|
+
const appFolder = fs.mkdtempSync(path.join(os.tmpdir(), 'start-253-clink-'));
|
|
23
|
+
try {
|
|
24
|
+
return fn(new ExecutionStore({ appFolder, ...options }));
|
|
25
|
+
} finally {
|
|
26
|
+
fs.rmSync(appFolder, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function countParens(text) {
|
|
31
|
+
let depth = 0;
|
|
32
|
+
let inQuote = false;
|
|
33
|
+
for (const char of text) {
|
|
34
|
+
if (char === '"') {
|
|
35
|
+
inQuote = !inQuote;
|
|
36
|
+
} else if (!inQuote && char === '(') {
|
|
37
|
+
depth++;
|
|
38
|
+
} else if (!inQuote && char === ')') {
|
|
39
|
+
depth--;
|
|
40
|
+
if (depth < 0) {
|
|
41
|
+
return depth;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return depth;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('clinkReference (#253)', () => {
|
|
49
|
+
it('quotes text and doubles inner quotes', () => {
|
|
50
|
+
expect(clinkReference('plain')).toBe('"plain"');
|
|
51
|
+
expect(clinkReference(`say "hi"`)).toBe('"say ""hi"""');
|
|
52
|
+
expect(clinkReference('back\\slash')).toBe('"back\\slash"');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('ExecutionStore.buildClinkCreateQuery (#253)', () => {
|
|
57
|
+
it('builds one balanced create group with record-scoped property links', () => {
|
|
58
|
+
withStore({ useLinks: false }, (store) => {
|
|
59
|
+
const record = new ExecutionRecord({
|
|
60
|
+
uuid: UUID,
|
|
61
|
+
command: `echo "a" (b)`,
|
|
62
|
+
pid: 4242,
|
|
63
|
+
exitCode: 0,
|
|
64
|
+
options: { isolated: 'docker' },
|
|
65
|
+
});
|
|
66
|
+
const query = store.buildClinkCreateQuery(record);
|
|
67
|
+
const properties = store.recordToLinksNotation(record);
|
|
68
|
+
// Keep record-derived query contents out of assertion failure logs.
|
|
69
|
+
expect(
|
|
70
|
+
query === `() ((${UUID}: ExecutionRecord ${UUID}) ${properties})`
|
|
71
|
+
).toBe(true);
|
|
72
|
+
expect(properties).not.toContain('ExecutionRecord');
|
|
73
|
+
|
|
74
|
+
expect(countParens(query)).toBe(0);
|
|
75
|
+
expect(query.startsWith(`() ((${UUID}: ExecutionRecord ${UUID}) `)).toBe(
|
|
76
|
+
true
|
|
77
|
+
);
|
|
78
|
+
expect(query).toContain(
|
|
79
|
+
`(${UUID}.command: ${UUID} "command=echo ""a"" (b)")`
|
|
80
|
+
);
|
|
81
|
+
// Numbers carry their key, so clink never reads them as link addresses
|
|
82
|
+
expect(query).toContain(`(${UUID}.exitCode: ${UUID} "exitCode=0")`);
|
|
83
|
+
expect(query).toContain(`(${UUID}.pid: ${UUID} "pid=4242")`);
|
|
84
|
+
expect(query).toContain(
|
|
85
|
+
`(${UUID}.options: ${UUID} "options={""isolated"":""docker""}")`
|
|
86
|
+
);
|
|
87
|
+
expect(query).not.toContain('\\"');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('ExecutionStore links database with clink (#253)', () => {
|
|
93
|
+
const clink = isClinkInstalled();
|
|
94
|
+
|
|
95
|
+
it.skipIf(!clink)(
|
|
96
|
+
'saves, updates and deletes records that share values',
|
|
97
|
+
() => {
|
|
98
|
+
withStore({}, (store) => {
|
|
99
|
+
expect(store.useLinks).toBe(true);
|
|
100
|
+
const first = new ExecutionRecord({
|
|
101
|
+
command: `echo 'it''s' "quoted" (x) \\ done`,
|
|
102
|
+
pid: 4242,
|
|
103
|
+
options: { isolated: 'docker' },
|
|
104
|
+
});
|
|
105
|
+
// Same status, pid and options: the property links must not collide
|
|
106
|
+
const second = new ExecutionRecord({
|
|
107
|
+
command: 'echo second',
|
|
108
|
+
pid: 4242,
|
|
109
|
+
options: { isolated: 'docker' },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(store.writeLinksRecord(first)).toBe(true);
|
|
113
|
+
expect(store.writeLinksRecord(second)).toBe(true);
|
|
114
|
+
|
|
115
|
+
store.save(first);
|
|
116
|
+
store.save(second);
|
|
117
|
+
let consistency = store.verifyConsistency();
|
|
118
|
+
expect(consistency.errors).toEqual([]);
|
|
119
|
+
expect(consistency.linksCount).toBe(2);
|
|
120
|
+
|
|
121
|
+
first.complete(3);
|
|
122
|
+
store.save(first);
|
|
123
|
+
const links = store.execClink('((($id: $s $t)) (($id: $s $t)))', [
|
|
124
|
+
'--changes',
|
|
125
|
+
]).output;
|
|
126
|
+
expect(links).toContain(
|
|
127
|
+
`${first.uuid}.status: ${first.uuid} status=executed`
|
|
128
|
+
);
|
|
129
|
+
expect(links).not.toContain(
|
|
130
|
+
`${first.uuid}.status: ${first.uuid} status=executing`
|
|
131
|
+
);
|
|
132
|
+
expect(links).toContain(
|
|
133
|
+
`${second.uuid}.status: ${second.uuid} status=executing`
|
|
134
|
+
);
|
|
135
|
+
expect(links).toContain(
|
|
136
|
+
`${first.uuid}.exitCode: ${first.uuid} exitCode=3`
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(store.delete(first.uuid)).toBe(true);
|
|
140
|
+
consistency = store.verifyConsistency();
|
|
141
|
+
expect(consistency.errors).toEqual([]);
|
|
142
|
+
expect(consistency.linksCount).toBe(1);
|
|
143
|
+
const remaining = store.execClink('((($id: $s $t)) (($id: $s $t)))', [
|
|
144
|
+
'--changes',
|
|
145
|
+
]).output;
|
|
146
|
+
expect(remaining).not.toContain(`${first.uuid}.`);
|
|
147
|
+
expect(remaining).toContain(`${second.uuid}.command`);
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
60000
|
|
151
|
+
);
|
|
152
|
+
});
|