functionalscript 0.39.0 → 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.
Files changed (95) hide show
  1. package/README.md +1 -1
  2. package/fjs/bnf/descent/module.f.d.ts +43 -2
  3. package/fjs/bnf/descent/module.f.js +37 -12
  4. package/fjs/bnf/descent/proof.f.d.ts +1 -0
  5. package/fjs/bnf/descent/proof.f.js +73 -32
  6. package/fjs/bnf/ll1/module.f.js +2 -2
  7. package/fjs/cas/evo/module.f.d.ts +28 -8
  8. package/fjs/cas/evo/module.f.js +43 -10
  9. package/fjs/cas/evo/proof.f.d.ts +4 -0
  10. package/fjs/cas/evo/proof.f.js +82 -1
  11. package/fjs/ci/config/module.f.d.ts +10 -7
  12. package/fjs/ci/config/module.f.js +23 -8
  13. package/fjs/ci/module.f.js +12 -5
  14. package/fjs/ci/nix/module.f.d.ts +61 -0
  15. package/fjs/ci/nix/module.f.js +92 -0
  16. package/fjs/ci/nix/proof.f.d.ts +23 -0
  17. package/fjs/ci/nix/proof.f.js +109 -0
  18. package/fjs/ci/node/module.f.d.ts +23 -1
  19. package/fjs/ci/node/module.f.js +47 -3
  20. package/fjs/ci/node/proof.f.d.ts +3 -0
  21. package/fjs/ci/node/proof.f.js +17 -0
  22. package/fjs/ci/proof.f.d.ts +2 -0
  23. package/fjs/ci/proof.f.js +47 -9
  24. package/fjs/dev/module.f.d.ts +1 -0
  25. package/fjs/dev/module.f.js +13 -2
  26. package/fjs/dev/update/module.f.d.ts +11 -0
  27. package/fjs/dev/update/module.f.js +20 -0
  28. package/fjs/dev/update/proof.f.d.ts +7 -0
  29. package/fjs/dev/update/proof.f.js +35 -0
  30. package/fjs/djs/ast/module.f.d.ts +47 -0
  31. package/fjs/djs/ast/module.f.js +9 -0
  32. package/fjs/djs/tokenizer/module.f.js +2 -2
  33. package/fjs/djs/tokenizer/proof.f.d.ts +1 -0
  34. package/fjs/djs/tokenizer/proof.f.js +70 -16
  35. package/fjs/effects/module.f.d.ts +20 -0
  36. package/fjs/effects/module.f.js +25 -1
  37. package/fjs/effects/node/module.d.ts +3 -3
  38. package/fjs/effects/node/module.f.d.ts +17 -7
  39. package/fjs/effects/node/module.f.js +22 -0
  40. package/fjs/effects/node/module.js +12 -12
  41. package/fjs/effects/node/proof.f.d.ts +1 -0
  42. package/fjs/effects/node/proof.f.js +14 -1
  43. package/fjs/effects/node/virtual/module.f.js +1 -1
  44. package/fjs/effects/proof.f.d.ts +5 -0
  45. package/fjs/effects/proof.f.js +22 -0
  46. package/fjs/emergent_testing/all.test.js +2 -1
  47. package/fjs/emergent_testing/module.f.d.ts +3 -3
  48. package/fjs/emergent_testing/module.f.js +8 -10
  49. package/fjs/emergent_testing/proof.f.js +2 -2
  50. package/fjs/emergent_testing/scenarios/thenable.pass.js +1 -1
  51. package/fjs/fsc/module.f.js +4 -4
  52. package/fjs/fsm/module.f.js +1 -1
  53. package/fjs/js/tokenizer/module.f.d.ts +1 -0
  54. package/fjs/js/tokenizer/module.f.js +13 -6
  55. package/fjs/{cas/mcp → mcp/cas}/module.f.d.ts +3 -25
  56. package/fjs/{cas/mcp → mcp/cas}/module.f.js +18 -58
  57. package/fjs/mcp/evo/module.f.d.ts +32 -0
  58. package/fjs/{cas/evo/mcp → mcp/evo}/module.f.js +19 -14
  59. package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.d.ts +1 -0
  60. package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.js +23 -7
  61. package/fjs/mcp/module.f.d.ts +54 -237
  62. package/fjs/mcp/module.f.js +55 -258
  63. package/fjs/mcp/proof.f.d.ts +43 -32
  64. package/fjs/mcp/proof.f.js +508 -200
  65. package/fjs/media/nix/module.f.d.ts +30 -0
  66. package/fjs/media/nix/module.f.js +166 -0
  67. package/fjs/media/nix/proof.f.d.ts +32 -0
  68. package/fjs/media/nix/proof.f.js +127 -0
  69. package/fjs/module.f.js +1 -1
  70. package/fjs/protocol/json_rpc/module.f.d.ts +114 -0
  71. package/fjs/{media/json/rpc → protocol/json_rpc}/module.f.js +3 -3
  72. package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.js +3 -3
  73. package/fjs/protocol/mcp/module.f.d.ts +239 -0
  74. package/fjs/protocol/mcp/module.f.js +272 -0
  75. package/fjs/protocol/mcp/proof.f.d.ts +34 -0
  76. package/fjs/protocol/mcp/proof.f.js +208 -0
  77. package/fjs/{mcp → protocol/mcp}/stdio/module.f.d.ts +5 -5
  78. package/fjs/{mcp → protocol/mcp}/stdio/module.f.js +11 -11
  79. package/fjs/{mcp → protocol/mcp}/stdio/proof.f.js +9 -9
  80. package/fjs/types/range_map/module.f.d.ts +14 -13
  81. package/fjs/types/range_map/module.f.js +18 -13
  82. package/fjs/types/range_map/proof.f.js +26 -39
  83. package/fjs/types/range_set/module.f.d.ts +5 -0
  84. package/fjs/types/range_set/module.f.js +16 -0
  85. package/fjs/types/range_set/proof.f.d.ts +1 -0
  86. package/fjs/types/range_set/proof.f.js +19 -0
  87. package/package.json +5 -5
  88. package/fjs/cas/evo/mcp/module.f.d.ts +0 -27
  89. package/fjs/cas/mcp/proof.f.d.ts +0 -45
  90. package/fjs/cas/mcp/proof.f.js +0 -545
  91. package/fjs/ci/playwright/module.f.d.ts +0 -2
  92. package/fjs/ci/playwright/module.f.js +0 -25
  93. package/fjs/media/json/rpc/module.f.d.ts +0 -114
  94. /package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.d.ts +0 -0
  95. /package/fjs/{mcp → protocol/mcp}/stdio/proof.f.d.ts +0 -0
@@ -183,6 +183,87 @@ export const proof = {
183
183
  assertEq(subjects.length, 1);
184
184
  assertEq(subjects[0], 'doc');
185
185
  },
186
+ // `list()` and `list(true)` partition subjects by the archived flags of
187
+ // their current heads: a lone root revision carrying `archived: true`
188
+ // makes its subject archived, one without it leaves the subject active,
189
+ // and neither subject appears in the other's result.
190
+ listPartitionsSubjectsByHeadArchivedFlag: () => {
191
+ const c = fileCas(sha256)(home);
192
+ const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
193
+ const e = evo(c)(cacheKey);
194
+ const [state1, live] = virtual(state0)(e.add({ parents: [], subject: 'live', snapshot: vecToCBase32(vec8(0x40n)) }));
195
+ assert(live[0] === 'ok', ['expected the active add to succeed', live]);
196
+ const [state2, gone] = virtual(state1)(e.add({ parents: [], subject: 'gone', snapshot: vecToCBase32(vec8(0x41n)), archived: true }));
197
+ assert(gone[0] === 'ok', ['expected the archived add to succeed', gone]);
198
+ const [state3, active] = virtual(state2)(e.list());
199
+ assertEq(active.length, 1);
200
+ assertEq(active[0], 'live');
201
+ const [, archived] = virtual(state3)(e.list(true));
202
+ assertEq(archived.length, 1);
203
+ assertEq(archived[0], 'gone');
204
+ },
205
+ // Concurrent heads can disagree about `archived`. One unarchived head
206
+ // keeps the whole subject active — there is still a head left to build on
207
+ // — so the subject stays out of the archived-only result.
208
+ listTreatsDisagreeingHeadsAsActive: () => {
209
+ const c = fileCas(sha256)(home);
210
+ const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
211
+ const e = evo(c)(cacheKey);
212
+ const [state1, root] = virtual(state0)(e.add({ parents: [], subject: 'doc', snapshot: vecToCBase32(vec8(0x42n)) }));
213
+ assert(root[0] === 'ok', ['expected root ok', root]);
214
+ // The two children differ only in `archived`, which is enough to make
215
+ // them distinct blobs, hence two concurrent heads of one root.
216
+ const [state2, kept] = virtual(state1)(e.add({ parents: [root[1]], subject: 'doc' }));
217
+ assert(kept[0] === 'ok', ['expected the unarchived child ok', kept]);
218
+ const [state3, dropped] = virtual(state2)(e.add({ parents: [root[1]], subject: 'doc', archived: true }));
219
+ assert(dropped[0] === 'ok', ['expected the archived child ok', dropped]);
220
+ const [state4, heads] = virtual(state3)(e.head('doc'));
221
+ assertEq(heads.length, 2);
222
+ const [state5, active] = virtual(state4)(e.list());
223
+ assertEq(active.length, 1);
224
+ assertEq(active[0], 'doc');
225
+ const [, archived] = virtual(state5)(e.list(true));
226
+ assertEq(archived.length, 0);
227
+ },
228
+ // `archived` is a property of a revision, not of a subject: a subject
229
+ // archived at one revision is active again as soon as an unarchived child
230
+ // demotes that revision out of the head set.
231
+ listIgnoresArchivedRevisionsThatAreNoLongerHeads: () => {
232
+ const c = fileCas(sha256)(home);
233
+ const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
234
+ const e = evo(c)(cacheKey);
235
+ const [state1, root] = virtual(state0)(e.add({ parents: [], subject: 'doc', snapshot: vecToCBase32(vec8(0x45n)), archived: true }));
236
+ assert(root[0] === 'ok', ['expected the archived root ok', root]);
237
+ const [state2, revived] = virtual(state1)(e.add({ parents: [root[1]], subject: 'doc' }));
238
+ assert(revived[0] === 'ok', ['expected the unarchived child ok', revived]);
239
+ const [state3, active] = virtual(state2)(e.list());
240
+ assertEq(active.length, 1);
241
+ assertEq(active[0], 'doc');
242
+ const [, archived] = virtual(state3)(e.list(true));
243
+ assertEq(archived.length, 0);
244
+ },
245
+ // A subject can end up with no current head at all, and a status is a
246
+ // statement about heads — so such a subject is neither active nor
247
+ // archived and belongs to neither result. Nothing verifies that a stored
248
+ // blob actually hashes to the key it sits under, so a hand-crafted or
249
+ // corrupt store can present a revision naming its own hash as its parent;
250
+ // `fixedCas` reproduces exactly that, which a real `fileCas` cannot.
251
+ listExcludesSubjectWithNoCurrentHeads: () => {
252
+ const selfHash = vec8(0x43n);
253
+ const snapshotHash = vecToCBase32(vec8(0x44n));
254
+ const text = `{"dialect":"${revisionDialect}","subject":"doc","parents":["${vecToCBase32(selfHash)}"],"snapshot":"${snapshotHash}","generation":1}`;
255
+ const bytes = tryUtf8(text);
256
+ assert(bytes !== null, 'expected the sample revision text to encode as UTF-8');
257
+ const cas = fixedCas([[selfHash, bytes]]);
258
+ const [state0, cacheKey] = virtual(emptyState)(initEvo(cas));
259
+ const e = evo(cas)(cacheKey);
260
+ const [state1, heads] = virtual(state0)(e.head('doc'));
261
+ assertEq(heads.length, 0);
262
+ const [state2, active] = virtual(state1)(e.list());
263
+ assertEq(active.length, 0);
264
+ const [, archived] = virtual(state2)(e.list(true));
265
+ assertEq(archived.length, 0);
266
+ },
186
267
  // Adding the exact same revision twice yields the same (deduplicated)
187
268
  // content hash and must not duplicate the head entry.
188
269
  addRevisionIdempotentOnDuplicateContent: () => {
@@ -518,7 +599,7 @@ export const proof = {
518
599
  // A raw CAS write (e.g. `cas_add`) of valid revision content is folded
519
600
  // into the cache exactly as `addRevision` would, without going through
520
601
  // `evo.add` — this is what keeps `cas_add` and `evo_add` writes to the
521
- // same store consistent (see `fjs/cas/mcp`).
602
+ // same store consistent (see `fjs/mcp`).
522
603
  syncRevisionFoldsValidRevisionIntoCache: () => {
523
604
  const c = fileCas(sha256)(home);
524
605
  const [state0, cacheKey] = virtual(emptyState)(initEvo(c));
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Centralized version pins and OS images used by the CI generator: runner
3
- * images, tool versions (Bun, Deno, Playwright, Rust, Node, Wasmtime, Wasmer,
4
- * TSGO).
3
+ * images, tool versions (Bun, Deno, Rust, Node, Wasmtime, Wasmer, TSGO).
5
4
  *
6
5
  * @module
7
6
  */
@@ -19,16 +18,19 @@ export declare const images: {
19
18
  readonly arm: 'windows-11-arm';
20
19
  };
21
20
  };
22
- export declare const functionalscript: '0.38.0';
21
+ export declare const functionalscript: '0.39.0';
23
22
  export declare const bun = "1.3.14";
24
23
  export declare const deno = "2.9.4";
25
- export declare const playwright = "1.62.0";
26
24
  export declare const node: {
27
- readonly default: '26.5.0';
28
- readonly node22: '22.23.1';
25
+ readonly default: '26.5.1';
26
+ readonly node22: '22.23.2';
29
27
  readonly node24: '24.18.0';
30
28
  };
31
- export declare const wasmtime = "47.0.2";
29
+ export declare const nixpkgs: {
30
+ readonly ref: 'nixos-26.05';
31
+ readonly commit: '6d65bfc1bcef2ef39a239d38e577e92a89fb0f07';
32
+ };
33
+ export declare const wasmtime = "47.0.3";
32
34
  export declare const wasmer = "7.2.1";
33
35
  export declare const actions: {
34
36
  readonly 'actions/checkout': 'v7.0.1';
@@ -38,5 +40,6 @@ export declare const actions: {
38
40
  readonly 'oven-sh/setup-bun': 'v2.2.0';
39
41
  readonly 'bytecodealliance/actions/wasmtime/setup': 'v1.1.3';
40
42
  readonly 'wasmerio/setup-wasmer': 'v3.1';
43
+ readonly 'cachix/install-nix-action': 'v31.11.0';
41
44
  readonly 'dtolnay/rust-toolchain': '1.97.1';
42
45
  };
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Centralized version pins and OS images used by the CI generator: runner
3
- * images, tool versions (Bun, Deno, Playwright, Rust, Node, Wasmtime, Wasmer,
4
- * TSGO).
3
+ * images, tool versions (Bun, Deno, Rust, Node, Wasmtime, Wasmer, TSGO).
5
4
  *
6
5
  * @module
7
6
  */
@@ -24,21 +23,34 @@ export const images = {
24
23
  // published FunctionalScript release; do not tie it to package.json's current
25
24
  // in-repo version.
26
25
  // https://www.npmjs.com/package/functionalscript
27
- export const functionalscript = '0.38.0';
26
+ export const functionalscript = '0.39.0';
28
27
  // https://bun.sh/
29
28
  export const bun = '1.3.14';
30
29
  // https://deno.com/
31
30
  export const deno = '2.9.4';
32
- // https://www.npmjs.com/package/playwright
33
- export const playwright = '1.62.0';
31
+ // The Node versions the pinned Nixpkgs snapshot below provides — read from
32
+ // `pkgs/development/web/nodejs/v{22,24,26}.nix` at that commit. Every runtime
33
+ // uses these: `setup-node` on the GitHub-hosted runners and the generated
34
+ // flakes on the Nix jobs, which assert the version they actually get. Nixpkgs
35
+ // usually trails nodejs.org, so bump the snapshot first and copy the versions
36
+ // it offers rather than the latest release.
34
37
  // https://nodejs.org/en/download
35
38
  export const node = {
36
- default: '26.5.0',
37
- node22: '22.23.1',
39
+ default: '26.5.1',
40
+ node22: '22.23.2',
38
41
  node24: '24.18.0',
39
42
  };
43
+ // Official Nixpkgs snapshot used by the generated CI flakes. `ref` is the
44
+ // stable channel the commit is accepted from; `commit` is the exact revision
45
+ // every generated `flake.nix` pins. The Node versions above come from this
46
+ // snapshot, so the two move together.
47
+ // https://channels.nixos.org/nixos-26.05/git-revision
48
+ export const nixpkgs = {
49
+ ref: 'nixos-26.05',
50
+ commit: '6d65bfc1bcef2ef39a239d38e577e92a89fb0f07',
51
+ };
40
52
  // https://github.com/bytecodealliance/wasmtime/releases
41
- export const wasmtime = '47.0.2';
53
+ export const wasmtime = '47.0.3';
42
54
  // https://github.com/wasmerio/wasmer/releases
43
55
  export const wasmer = '7.2.1';
44
56
  // GitHub Action versions used by CI step builders. The key is the action
@@ -60,6 +72,9 @@ export const actions = {
60
72
  'bytecodealliance/actions/wasmtime/setup': 'v1.1.3',
61
73
  // https://github.com/wasmerio/setup-wasmer
62
74
  'wasmerio/setup-wasmer': 'v3.1',
75
+ // https://github.com/marketplace/actions/install-nix
76
+ // Enables the `nix-command` and `flakes` experimental features by default.
77
+ 'cachix/install-nix-action': 'v31.11.0',
63
78
  // https://rust-lang.org/ - value is Rust version, not action version
64
79
  'dtolnay/rust-toolchain': '1.97.1',
65
80
  };
@@ -3,13 +3,13 @@
3
3
  *
4
4
  * @module
5
5
  */
6
- import { pure, step } from '../effects/module.f.js';
6
+ import { mapStep, step } from '../effects/module.f.js';
7
7
  import { access, writeUtf8File } from '../effects/node/module.f.js';
8
8
  import { functionalscript, images } from './config/module.f.js';
9
9
  import { architecture, os, toSteps, ubuntuArm } from './common/module.f.js';
10
10
  import { rustPlatformSteps, rustWasmSteps } from './rust/module.f.js';
11
- import { nodeMainSteps, nodeVersionJobs } from './node/module.f.js';
12
- import { playwrightJob } from './playwright/module.f.js';
11
+ import { nodeMainSteps, nodeNixJobs, nodeNixVersionSteps, nodeVersionJobs } from './node/module.f.js';
12
+ import { nixFlakes, nixInstall } from './nix/module.f.js';
13
13
  import { bunSteps } from './bun/module.f.js';
14
14
  import { denoSteps } from './deno/module.f.js';
15
15
  const job = (rust, nodeExtra) => (o) => (a) => {
@@ -22,12 +22,17 @@ const job = (rust, nodeExtra) => (o) => (a) => {
22
22
  ];
23
23
  return [id, { 'runs-on': image, steps: toSteps(result) }];
24
24
  };
25
+ // Every generated flake, across all job families that own one.
26
+ const nixJobs = nodeNixJobs;
27
+ // Temporary: proves the not-yet-migrated flakes still evaluate. Removed once
28
+ // the canonical Node jobs check their own flake by running through it.
29
+ const nixFlakeJob = ubuntuArm([nixInstall, ...nodeNixVersionSteps]);
25
30
  const canonicalJobs = (rust) => ({
26
31
  ...(rust ? { wasm: ubuntuArm(rustWasmSteps) } : {}),
27
32
  deno: ubuntuArm(denoSteps(functionalscript)),
28
33
  bun: ubuntuArm(bunSteps(functionalscript)),
29
34
  ...nodeVersionJobs(functionalscript),
30
- playwright: playwrightJob,
35
+ 'nix-flakes': nixFlakeJob,
31
36
  });
32
37
  export const ci = ({ nodeExtra }) => step(access('Cargo.toml'), result => {
33
38
  const rust = result[0] === 'ok';
@@ -46,6 +51,8 @@ export const ci = ({ nodeExtra }) => step(access('Cargo.toml'), result => {
46
51
  },
47
52
  jobs,
48
53
  };
49
- return step(writeUtf8File('.github/workflows/ci.yml', JSON.stringify(gha, null, ' ')), () => pure(0));
54
+ const workflowWritten = writeUtf8File('.github/workflows/ci.yml', JSON.stringify(gha, null, ' '));
55
+ const flakesWritten = step(workflowWritten, () => nixFlakes(nixJobs));
56
+ return mapStep(flakesWritten, () => 0);
50
57
  });
51
58
  export const main = () => ci({ nodeExtra: () => [] });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Generates one self-contained Nix flake per declared CI job.
3
+ *
4
+ * Each job gets its own `nix/generated/<id>/flake.nix` pinning the exact
5
+ * Nixpkgs commit from `../config/module.f.ts` and exposing a single
6
+ * `devShells.<system>.default` development shell. The files are static and
7
+ * readable on purpose: no job selection, no shared Nix modules, no helper
8
+ * libraries.
9
+ *
10
+ * @module
11
+ */
12
+ import { type Effect } from '../../effects/module.f.ts';
13
+ import { type Mkdir, type WriteFile } from '../../effects/node/module.f.ts';
14
+ import { type MetaStep } from '../common/module.f.ts';
15
+ /** A CI job's development environment, one generated flake each. */
16
+ export type NixJob = {
17
+ /** Generated directory name under `nix/generated`, matching the CI job id. */
18
+ readonly id: string;
19
+ /** Nix system of the job's runner, e.g. `aarch64-linux`. */
20
+ readonly system: string;
21
+ /** Nixpkgs attribute names made available in the job's shell. */
22
+ readonly packages: readonly string[];
23
+ /** Job-local shell initialization, when the job needs one. */
24
+ readonly shellHook?: string;
25
+ };
26
+ /** Directory owned by this generator. */
27
+ export declare const generatedDirectory: 'nix/generated';
28
+ /**
29
+ * Serializes a job's flake.
30
+ *
31
+ * The serializer rejects invalid *identifiers*, and every identifier in a flake
32
+ * is written here — a job only contributes attribute names and strings, which
33
+ * are quoted when they are not identifiers. The unwrap is therefore a totality
34
+ * assertion, not an input check.
35
+ */
36
+ export declare const flakeText: (job: NixJob) => string;
37
+ /** Writes one generated flake per job. */
38
+ export declare const nixFlakes: (jobs: readonly NixJob[]) => Effect<Mkdir | WriteFile, void>;
39
+ /** Path a workflow passes to `nix develop`, for the job of the given id. */
40
+ export declare const flakePath: (id: string) => string;
41
+ /** Installs Nix, with `nix-command` and `flakes` enabled by the action's defaults. */
42
+ export declare const nixInstall: MetaStep;
43
+ /** Runs one command inside a job's generated development shell. */
44
+ export declare const nixDevelop: (id: string, command: string) => string;
45
+ /**
46
+ * Runs a migrated job's whole command sequence in one development shell, so the
47
+ * shell's packages and environment reach every command without exporting a
48
+ * profile across GitHub Actions steps.
49
+ *
50
+ * The commands are a shell script, joined so a failure stops the rest, and are
51
+ * quoted as one argument — a command may contain quotes of its own.
52
+ */
53
+ export declare const nixDevelopAll: (id: string, commands: readonly string[]) => string;
54
+ /**
55
+ * Checks a job's generated flake end to end: the shell builds, and the Node it
56
+ * puts on `PATH` is exactly the pinned version. The pinned Nixpkgs commit
57
+ * already determines the version, so this is the only place the expectation
58
+ * is stated — the generated flakes stay declarative instead of carrying an
59
+ * `assert` that restates the commit they pin.
60
+ */
61
+ export declare const nixVersionCheckStep: (id: string, version: string) => MetaStep;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Generates one self-contained Nix flake per declared CI job.
3
+ *
4
+ * Each job gets its own `nix/generated/<id>/flake.nix` pinning the exact
5
+ * Nixpkgs commit from `../config/module.f.ts` and exposing a single
6
+ * `devShells.<system>.default` development shell. The files are static and
7
+ * readable on purpose: no job selection, no shared Nix modules, no helper
8
+ * libraries.
9
+ *
10
+ * @module
11
+ */
12
+ import { forEachStep, mapStep, pure, step } from '../../effects/module.f.js';
13
+ import { mkdir, writeUtf8File } from '../../effects/node/module.f.js';
14
+ import { nixToString } from '../../media/nix/module.f.js';
15
+ import { fromUndefined, unwrap as unwrapNullable } from '../../types/nullable/module.f.js';
16
+ import { unwrap } from '../../types/result/module.f.js';
17
+ import { install, test, uses } from '../common/module.f.js';
18
+ import { nixpkgs } from '../config/module.f.js';
19
+ /** Directory owned by this generator. */
20
+ export const generatedDirectory = 'nix/generated';
21
+ const { commit } = nixpkgs;
22
+ const url = `github:NixOS/nixpkgs/${commit}`;
23
+ const flake = ({ system, packages, shellHook }) => ['set',
24
+ ['=', ['inputs', 'nixpkgs', 'url'], url],
25
+ ['=', ['outputs'], ['lambda',
26
+ ['open-set-pattern', 'nixpkgs'],
27
+ ['set',
28
+ ['=', ['devShells', system, 'default'], ['let',
29
+ [['=', ['pkgs'], ['apply',
30
+ ['ref', 'import'],
31
+ ['ref', 'nixpkgs'],
32
+ ['set', ['=', ['system'], system]]
33
+ ]]],
34
+ ['apply',
35
+ ['ref', 'pkgs', 'mkShell'],
36
+ ['set',
37
+ ['=', ['packages'], ['list', ...packages.map(p => ['ref', 'pkgs', p])]],
38
+ ...(shellHook === undefined
39
+ ? []
40
+ : [['=', ['shellHook'], ['indented-string', shellHook]]])
41
+ ]
42
+ ]
43
+ ]]
44
+ ]
45
+ ]]
46
+ ];
47
+ /**
48
+ * Serializes a job's flake.
49
+ *
50
+ * The serializer rejects invalid *identifiers*, and every identifier in a flake
51
+ * is written here — a job only contributes attribute names and strings, which
52
+ * are quoted when they are not identifiers. The unwrap is therefore a totality
53
+ * assertion, not an input check.
54
+ */
55
+ export const flakeText = (job) => unwrapNullable(fromUndefined(nixToString(flake(job))));
56
+ const writeFlake = (job) => {
57
+ const directory = `${generatedDirectory}/${job.id}`;
58
+ const created = mapStep(mkdir(directory, { recursive: true }), unwrap);
59
+ const written = step(created, () => writeUtf8File(`${directory}/flake.nix`, flakeText(job)));
60
+ return mapStep(written, unwrap);
61
+ };
62
+ /** Writes one generated flake per job. */
63
+ export const nixFlakes = (jobs) => forEachStep(pure(jobs), writeFlake);
64
+ /** Path a workflow passes to `nix develop`, for the job of the given id. */
65
+ export const flakePath = (id) => `./${generatedDirectory}/${id}`;
66
+ /** Installs Nix, with `nix-command` and `flakes` enabled by the action's defaults. */
67
+ export const nixInstall = install(uses('cachix/install-nix-action'));
68
+ /** Runs one command inside a job's generated development shell. */
69
+ export const nixDevelop = (id, command) => `nix develop ${flakePath(id)} --command ${command}`;
70
+ /**
71
+ * Wraps a string so a POSIX shell reproduces it exactly. Single quotes protect
72
+ * every other character, so only the quote itself needs handling: leave the
73
+ * literal, reopen it, and escape the quote outside (`'` becomes `'\''`).
74
+ */
75
+ const singleQuoted = (value) => `'${value.replaceAll("'", "'\\''")}'`;
76
+ /**
77
+ * Runs a migrated job's whole command sequence in one development shell, so the
78
+ * shell's packages and environment reach every command without exporting a
79
+ * profile across GitHub Actions steps.
80
+ *
81
+ * The commands are a shell script, joined so a failure stops the rest, and are
82
+ * quoted as one argument — a command may contain quotes of its own.
83
+ */
84
+ export const nixDevelopAll = (id, commands) => nixDevelop(id, `bash -euo pipefail -c ${singleQuoted(commands.join(' && '))}`);
85
+ /**
86
+ * Checks a job's generated flake end to end: the shell builds, and the Node it
87
+ * puts on `PATH` is exactly the pinned version. The pinned Nixpkgs commit
88
+ * already determines the version, so this is the only place the expectation
89
+ * is stated — the generated flakes stay declarative instead of carrying an
90
+ * `assert` that restates the commit they pin.
91
+ */
92
+ export const nixVersionCheckStep = (id, version) => test({ run: `test "$(${nixDevelop(id, 'node --version')})" = v${version}` });
@@ -0,0 +1,23 @@
1
+ export declare const proof: {
2
+ flakeText: {
3
+ plain: () => void;
4
+ shellHook: () => void;
5
+ };
6
+ nixFlakes: {
7
+ write: () => void;
8
+ every: () => void;
9
+ nodeShellHook: () => void;
10
+ packages: () => void;
11
+ quotedPackage: () => void;
12
+ multiplePackages: () => void;
13
+ };
14
+ workflow: {
15
+ flakePath: () => void;
16
+ nixDevelop: () => void;
17
+ nixDevelopAll: {
18
+ sequence: () => void;
19
+ quote: () => void;
20
+ };
21
+ nixInstall: () => void;
22
+ };
23
+ };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Proofs for generated CI flakes.
3
+ *
4
+ * @module
5
+ */
6
+ import { assert, assertEq } from '../../asserts/module.f.js';
7
+ import { step } from '../../effects/module.f.js';
8
+ import { readUtf8File } from '../../effects/node/module.f.js';
9
+ import { emptyState, virtual } from '../../effects/node/virtual/module.f.js';
10
+ import { nixpkgs } from '../config/module.f.js';
11
+ import { nodeNixJobs } from '../node/module.f.js';
12
+ import { flakePath, flakeText, generatedDirectory, nixDevelop, nixDevelopAll, nixFlakes, nixInstall, } from './module.f.js';
13
+ const { commit } = nixpkgs;
14
+ const plain = {
15
+ id: 'node24',
16
+ system: 'aarch64-linux',
17
+ packages: ['nodejs_24'],
18
+ };
19
+ const withShellHook = {
20
+ ...plain,
21
+ id: 'node22',
22
+ packages: ['nodejs_22'],
23
+ shellHook: `export NPM_CONFIG_PREFIX="$HOME/.npm-global"`,
24
+ };
25
+ const plainFlake = `{
26
+ inputs.nixpkgs.url = "github:NixOS/nixpkgs/${commit}";
27
+ outputs = { nixpkgs, ... }: {
28
+ devShells.aarch64-linux.default = let
29
+ pkgs = import nixpkgs {
30
+ system = "aarch64-linux";
31
+ };
32
+ in
33
+ pkgs.mkShell {
34
+ packages = [ pkgs.nodejs_24 ];
35
+ };
36
+ };
37
+ }
38
+ `;
39
+ const shellHookFlake = `{
40
+ inputs.nixpkgs.url = "github:NixOS/nixpkgs/${commit}";
41
+ outputs = { nixpkgs, ... }: {
42
+ devShells.aarch64-linux.default = let
43
+ pkgs = import nixpkgs {
44
+ system = "aarch64-linux";
45
+ };
46
+ in
47
+ pkgs.mkShell {
48
+ packages = [ pkgs.nodejs_22 ];
49
+ shellHook = ''
50
+ export NPM_CONFIG_PREFIX="$HOME/.npm-global"
51
+ '';
52
+ };
53
+ };
54
+ }
55
+ `;
56
+ const generated = (jobs, id) => {
57
+ const written = step(nixFlakes(jobs), () => readUtf8File(`${generatedDirectory}/${id}/flake.nix`));
58
+ const [, [tag, result]] = virtual(emptyState)(written);
59
+ assert(tag === 'ok', result);
60
+ return result;
61
+ };
62
+ export const proof = {
63
+ flakeText: {
64
+ plain: () => assertEq(flakeText(plain), plainFlake),
65
+ shellHook: () => assertEq(flakeText(withShellHook), shellHookFlake),
66
+ },
67
+ nixFlakes: {
68
+ write: () => assertEq(generated([plain], plain.id), plainFlake),
69
+ every: () => {
70
+ for (const job of nodeNixJobs) {
71
+ assertEq(generated(nodeNixJobs, job.id), flakeText(job));
72
+ }
73
+ },
74
+ nodeShellHook: () => {
75
+ const [node22] = nodeNixJobs;
76
+ assert(node22.shellHook !== undefined, 'expected a Node 22 shell hook');
77
+ assert(generated(nodeNixJobs, node22.id).includes('$HOME/.npm-global'), 'expected the Node 22 global installation prefix');
78
+ },
79
+ packages: () => {
80
+ for (const { id, packages } of nodeNixJobs) {
81
+ assertEq(packages.length, 1);
82
+ assertEq(packages[0], `nodejs_${id.slice('node'.length)}`);
83
+ }
84
+ },
85
+ // Job data only ever reaches quotable positions, so an unusual package
86
+ // name is escaped rather than rejected.
87
+ quotedPackage: () => assert(flakeText({ ...plain, packages: ['not an identifier'] })
88
+ .includes('pkgs."not an identifier"'), 'expected a quoted attribute name'),
89
+ multiplePackages: () => assert(flakeText({ ...plain, packages: ['nodejs_24', 'git'] })
90
+ .includes('[ pkgs.nodejs_24 pkgs.git ]'), 'expected both packages in the shell'),
91
+ },
92
+ workflow: {
93
+ // The path a workflow passes to `nix develop` must be the directory the
94
+ // generator wrote the flake into.
95
+ flakePath: () => assertEq(flakePath(plain.id), `./${generatedDirectory}/node24`),
96
+ nixDevelop: () => assertEq(nixDevelop(plain.id, 'node --version'), 'nix develop ./nix/generated/node24 --command node --version'),
97
+ nixDevelopAll: {
98
+ sequence: () => assertEq(nixDevelopAll(plain.id, ['npm ci', 'node --test']), `nix develop ./nix/generated/node24 --command bash -euo pipefail -c 'npm ci && node --test'`),
99
+ // A command carrying its own quote must not end the outer one: the
100
+ // shell has to see the script back exactly as it was written.
101
+ quote: () => assertEq(nixDevelopAll(plain.id, [`printf '%s' 'a b'`]), `nix develop ./nix/generated/node24 --command bash -euo pipefail -c 'printf '\\''%s'\\'' '\\''a b'\\'''`),
102
+ },
103
+ nixInstall: () => {
104
+ assertEq(nixInstall.type, 'install');
105
+ assert(nixInstall.type === 'install'
106
+ && nixInstall.step.uses?.startsWith('cachix/install-nix-action@') === true, 'expected the pinned Nix installer action');
107
+ },
108
+ },
109
+ };
@@ -1,6 +1,28 @@
1
- import { type Jobs, type MetaStep } from '../common/module.f.ts';
1
+ import { type Job, type Jobs, type MetaStep } from '../common/module.f.ts';
2
+ import { type NixJob } from '../nix/module.f.ts';
2
3
  export declare const major: (v: string) => string;
3
4
  export declare const basicNode: (version: string) => (extra: readonly MetaStep[]) => readonly MetaStep[];
4
5
  export declare const platformNodeSteps: (version: string) => readonly MetaStep[];
5
6
  export declare const nodeVersionJobs: (version: string) => Jobs;
7
+ export declare const nixSystem: 'aarch64-linux';
8
+ /** Generated development environments for the canonical Node jobs. */
9
+ export declare const nodeNixJobs: readonly NixJob[];
10
+ /**
11
+ * Version-check steps for the canonical Node jobs' generated flakes, one per
12
+ * job. Collected into the shared temporary `nix-flakes` job in
13
+ * `fjs/ci/module.f.ts`.
14
+ */
15
+ export declare const nodeNixVersionSteps: readonly MetaStep[];
16
+ /**
17
+ * Temporary job that instantiates every generated flake.
18
+ *
19
+ * Nothing else in CI evaluates the generated files, so a broken flake — or one
20
+ * whose snapshot moved to a different Node — would only surface once a real job
21
+ * started using it. It deliberately stays separate from the canonical Node jobs:
22
+ * those keep their current `setup-node` runtime until they are migrated one at a
23
+ * time. When the last one migrates and this job goes away, each migrated job
24
+ * must check its own Node version inside the `nix develop` invocation, or the
25
+ * guarantee is lost.
26
+ */
27
+ export declare const nodeNixFlakeJob: Job;
6
28
  export declare const nodeMainSteps: typeof platformNodeSteps;
@@ -6,7 +6,9 @@
6
6
  */
7
7
  import { node } from '../config/module.f.js';
8
8
  import { install, test, ubuntuArm, uses } from '../common/module.f.js';
9
+ import { nixInstall, nixVersionCheckStep } from '../nix/module.f.js';
9
10
  export const major = (v) => v.split('.')[0];
11
+ const jobId = (version) => `node${major(version)}`;
10
12
  const installNode = (version) => uses('actions/setup-node', { 'node-version': version });
11
13
  const nodeInstall = (v) => [
12
14
  install(installNode(v)),
@@ -26,6 +28,7 @@ const node22Steps = (version) => [
26
28
  ...nodeInstall(node.node22),
27
29
  fjsGlobalInstall(version),
28
30
  test({ run: 'fjs t' }),
31
+ test({ run: 'node --test' }),
29
32
  ];
30
33
  const node24Steps = [
31
34
  ...nodeInstall(node.node24),
@@ -41,8 +44,49 @@ const node26Steps = [
41
44
  ];
42
45
  const nodeJob = (steps) => ubuntuArm(steps);
43
46
  export const nodeVersionJobs = (version) => ({
44
- [`node${major(node.node22)}`]: nodeJob(node22Steps(version)),
45
- [`node${major(node.node24)}`]: nodeJob(node24Steps),
46
- [`node${major(node.default)}`]: nodeJob(node26Steps),
47
+ [jobId(node.node22)]: nodeJob(node22Steps(version)),
48
+ [jobId(node.node24)]: nodeJob(node24Steps),
49
+ [jobId(node.default)]: nodeJob(node26Steps),
50
+ });
51
+ // The canonical Node jobs run on the Ubuntu ARM runner.
52
+ export const nixSystem = 'aarch64-linux';
53
+ // Keeps `npm install -g functionalscript` writable and puts the installed `fjs`
54
+ // on `PATH` for the rest of the same `nix develop` invocation.
55
+ const npmGlobalShellHook = `export NPM_CONFIG_PREFIX="$HOME/.npm-global"
56
+ export PATH="$NPM_CONFIG_PREFIX/bin:$PATH"
57
+ mkdir -p "$NPM_CONFIG_PREFIX"`;
58
+ // Versions of the canonical Node jobs, in job order.
59
+ const nixVersions = [node.node22, node.node24, node.default];
60
+ const nixJob = (version) => ({
61
+ id: jobId(version),
62
+ system: nixSystem,
63
+ packages: [`nodejs_${major(version)}`],
47
64
  });
65
+ /** Generated development environments for the canonical Node jobs. */
66
+ export const nodeNixJobs = [
67
+ { ...nixJob(node.node22), shellHook: npmGlobalShellHook },
68
+ nixJob(node.node24),
69
+ nixJob(node.default),
70
+ ];
71
+ /**
72
+ * Version-check steps for the canonical Node jobs' generated flakes, one per
73
+ * job. Collected into the shared temporary `nix-flakes` job in
74
+ * `fjs/ci/module.f.ts`.
75
+ */
76
+ export const nodeNixVersionSteps = nixVersions.map(version => nixVersionCheckStep(jobId(version), version));
77
+ /**
78
+ * Temporary job that instantiates every generated flake.
79
+ *
80
+ * Nothing else in CI evaluates the generated files, so a broken flake — or one
81
+ * whose snapshot moved to a different Node — would only surface once a real job
82
+ * started using it. It deliberately stays separate from the canonical Node jobs:
83
+ * those keep their current `setup-node` runtime until they are migrated one at a
84
+ * time. When the last one migrates and this job goes away, each migrated job
85
+ * must check its own Node version inside the `nix develop` invocation, or the
86
+ * guarantee is lost.
87
+ */
88
+ export const nodeNixFlakeJob = ubuntuArm([
89
+ nixInstall,
90
+ ...nodeNixVersionSteps,
91
+ ]);
48
92
  export const nodeMainSteps = platformNodeSteps;
@@ -0,0 +1,3 @@
1
+ export declare const proof: {
2
+ basicNode: () => void;
3
+ };
@@ -0,0 +1,17 @@
1
+ import { basicNode } from './module.f.js';
2
+ import { test } from '../common/module.f.js';
3
+ import { assertEq } from '../../asserts/module.f.js';
4
+ export const proof = {
5
+ basicNode: () => {
6
+ const extra = [test({ run: 'echo extra' })];
7
+ const steps = basicNode('22.0.0')(extra);
8
+ assertEq(steps.length, 3);
9
+ const [setupNode, npmCi, extraStep] = steps;
10
+ assertEq(setupNode.type, 'install');
11
+ assertEq(setupNode.type === 'install' ? setupNode.step.uses : undefined, 'actions/setup-node@v7.0.0');
12
+ assertEq(setupNode.type === 'install' ? setupNode.step.with?.['node-version'] : undefined, '22.0.0');
13
+ assertEq(npmCi.type, 'test');
14
+ assertEq(npmCi.type === 'test' ? npmCi.step.run : undefined, 'npm ci');
15
+ assertEq(extraStep, extra[0]);
16
+ },
17
+ };