functionalscript 0.38.0 → 0.40.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 (137) 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/bnf/token_symbol/module.f.d.ts +38 -0
  8. package/fjs/bnf/token_symbol/module.f.js +48 -0
  9. package/fjs/bnf/token_symbol/proof.f.d.ts +10 -0
  10. package/fjs/bnf/token_symbol/proof.f.js +37 -0
  11. package/fjs/cas/cli/module.f.js +6 -9
  12. package/fjs/cas/evo/module.f.d.ts +77 -13
  13. package/fjs/cas/evo/module.f.js +130 -18
  14. package/fjs/cas/evo/proof.f.d.ts +7 -0
  15. package/fjs/cas/evo/proof.f.js +120 -1
  16. package/fjs/cas/module.f.js +64 -37
  17. package/fjs/cas/proof.f.d.ts +3 -9
  18. package/fjs/cas/proof.f.js +94 -74
  19. package/fjs/ci/bun/module.f.js +3 -3
  20. package/fjs/ci/common/module.f.d.ts +1 -2
  21. package/fjs/ci/common/module.f.js +1 -5
  22. package/fjs/ci/config/module.f.d.ts +11 -8
  23. package/fjs/ci/config/module.f.js +24 -9
  24. package/fjs/ci/deno/module.f.js +3 -3
  25. package/fjs/ci/module.f.js +13 -7
  26. package/fjs/ci/nix/module.f.d.ts +61 -0
  27. package/fjs/ci/nix/module.f.js +92 -0
  28. package/fjs/ci/nix/proof.f.d.ts +23 -0
  29. package/fjs/ci/nix/proof.f.js +109 -0
  30. package/fjs/ci/node/module.f.d.ts +23 -1
  31. package/fjs/ci/node/module.f.js +58 -12
  32. package/fjs/ci/proof.f.d.ts +2 -0
  33. package/fjs/ci/proof.f.js +49 -9
  34. package/fjs/cli/module.f.js +2 -2
  35. package/fjs/common/monoid/module.f.d.ts +46 -2
  36. package/fjs/common/monoid/module.f.js +44 -0
  37. package/fjs/common/monoid/proof.f.d.ts +5 -0
  38. package/fjs/common/monoid/proof.f.js +27 -1
  39. package/fjs/dev/module.f.d.ts +1 -0
  40. package/fjs/dev/module.f.js +38 -27
  41. package/fjs/dev/update/module.f.d.ts +11 -0
  42. package/fjs/dev/update/module.f.js +20 -0
  43. package/fjs/dev/update/proof.f.d.ts +7 -0
  44. package/fjs/dev/update/proof.f.js +35 -0
  45. package/fjs/djs/ast/module.f.d.ts +47 -0
  46. package/fjs/djs/ast/module.f.js +9 -0
  47. package/fjs/djs/module.f.js +5 -8
  48. package/fjs/djs/parser/module.f.d.ts +3 -0
  49. package/fjs/djs/parser/module.f.js +17 -0
  50. package/fjs/djs/tokenizer/module.f.js +2 -2
  51. package/fjs/djs/tokenizer/proof.f.d.ts +1 -0
  52. package/fjs/djs/tokenizer/proof.f.js +70 -16
  53. package/fjs/djs/transpiler/module.f.js +6 -6
  54. package/fjs/effects/eff/module.f.d.ts +72 -0
  55. package/fjs/effects/eff/module.f.js +46 -0
  56. package/fjs/effects/eff/proof.f.d.ts +11 -0
  57. package/fjs/effects/eff/proof.f.js +58 -0
  58. package/fjs/effects/list/module.f.d.ts +11 -5
  59. package/fjs/effects/list/module.f.js +2 -1
  60. package/fjs/effects/memory/proof.f.js +7 -8
  61. package/fjs/effects/module.f.d.ts +334 -52
  62. package/fjs/effects/module.f.js +253 -45
  63. package/fjs/effects/node/memory/proof.js +3 -2
  64. package/fjs/effects/node/module.d.ts +3 -3
  65. package/fjs/effects/node/module.f.d.ts +18 -8
  66. package/fjs/effects/node/module.f.js +31 -11
  67. package/fjs/effects/node/module.js +12 -12
  68. package/fjs/effects/node/proof.f.d.ts +1 -0
  69. package/fjs/effects/node/proof.f.js +32 -11
  70. package/fjs/effects/node/virtual/module.f.js +1 -1
  71. package/fjs/effects/proof.f.d.ts +32 -6
  72. package/fjs/effects/proof.f.js +131 -39
  73. package/fjs/emergent_testing/all.test.js +2 -1
  74. package/fjs/emergent_testing/module.f.d.ts +3 -3
  75. package/fjs/emergent_testing/module.f.js +63 -49
  76. package/fjs/emergent_testing/proof.f.d.ts +4 -0
  77. package/fjs/emergent_testing/proof.f.js +54 -21
  78. package/fjs/emergent_testing/scenarios/thenable.pass.js +1 -1
  79. package/fjs/fsc/module.f.js +4 -4
  80. package/fjs/fsm/module.f.js +1 -1
  81. package/fjs/js/tokenizer/module.f.d.ts +1 -0
  82. package/fjs/js/tokenizer/module.f.js +13 -6
  83. package/fjs/{cas/mcp → mcp/cas}/module.f.d.ts +3 -25
  84. package/fjs/{cas/mcp → mcp/cas}/module.f.js +26 -64
  85. package/fjs/mcp/evo/module.f.d.ts +27 -0
  86. package/fjs/mcp/evo/module.f.js +94 -0
  87. package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.d.ts +2 -0
  88. package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.js +40 -9
  89. package/fjs/mcp/module.f.d.ts +54 -237
  90. package/fjs/mcp/module.f.js +55 -258
  91. package/fjs/mcp/proof.f.d.ts +43 -32
  92. package/fjs/mcp/proof.f.js +509 -200
  93. package/fjs/media/nix/module.f.d.ts +30 -0
  94. package/fjs/media/nix/module.f.js +166 -0
  95. package/fjs/media/nix/proof.f.d.ts +32 -0
  96. package/fjs/media/nix/proof.f.js +127 -0
  97. package/fjs/media/type/module.f.js +2 -2
  98. package/fjs/media/type/proof.f.js +7 -10
  99. package/fjs/module.f.js +5 -9
  100. package/fjs/protocol/json_rpc/module.f.d.ts +114 -0
  101. package/fjs/{media/json/rpc → protocol/json_rpc}/module.f.js +3 -3
  102. package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.js +3 -3
  103. package/fjs/protocol/mcp/module.f.d.ts +239 -0
  104. package/fjs/protocol/mcp/module.f.js +272 -0
  105. package/fjs/protocol/mcp/proof.f.d.ts +34 -0
  106. package/fjs/protocol/mcp/proof.f.js +208 -0
  107. package/fjs/{mcp → protocol/mcp}/stdio/module.f.d.ts +7 -7
  108. package/fjs/{mcp → protocol/mcp}/stdio/module.f.js +22 -22
  109. package/fjs/{mcp → protocol/mcp}/stdio/proof.f.js +9 -9
  110. package/fjs/types/bigint/module.f.js +4 -3
  111. package/fjs/types/bit_vec/module.f.js +5 -15
  112. package/fjs/types/btree/remove/module.f.d.ts +5 -0
  113. package/fjs/types/btree/remove/module.f.js +12 -0
  114. package/fjs/types/list/module.f.d.ts +21 -0
  115. package/fjs/types/list/module.f.js +17 -0
  116. package/fjs/types/list/proof.f.d.ts +1 -0
  117. package/fjs/types/list/proof.f.js +24 -1
  118. package/fjs/types/number/module.f.js +2 -1
  119. package/fjs/types/range_map/module.f.d.ts +14 -13
  120. package/fjs/types/range_map/module.f.js +18 -13
  121. package/fjs/types/range_map/proof.f.js +26 -39
  122. package/fjs/types/range_set/module.f.d.ts +5 -0
  123. package/fjs/types/range_set/module.f.js +16 -0
  124. package/fjs/types/range_set/proof.f.d.ts +1 -0
  125. package/fjs/types/range_set/proof.f.js +19 -0
  126. package/fjs/types/string/module.f.js +10 -2
  127. package/fjs/website/module.f.js +2 -3
  128. package/package.json +5 -5
  129. package/fjs/cas/evo/mcp/module.f.d.ts +0 -19
  130. package/fjs/cas/evo/mcp/module.f.js +0 -56
  131. package/fjs/cas/mcp/proof.f.d.ts +0 -45
  132. package/fjs/cas/mcp/proof.f.js +0 -545
  133. package/fjs/ci/playwright/module.f.d.ts +0 -2
  134. package/fjs/ci/playwright/module.f.js +0 -25
  135. package/fjs/media/json/rpc/module.f.d.ts +0 -114
  136. /package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.d.ts +0 -0
  137. /package/fjs/{mcp → protocol/mcp}/stdio/proof.f.d.ts +0 -0
@@ -5,9 +5,9 @@
5
5
  * @module
6
6
  */
7
7
  import { deno } from '../config/module.f.js';
8
- import { clean, install, test, uses } from '../common/module.f.js';
8
+ import { install, test, uses } from '../common/module.f.js';
9
9
  const denoTest = 'deno test --allow-read --allow-env --allow-sys';
10
- export const denoSteps = (version) => clean([
10
+ export const denoSteps = (version) => [
11
11
  install(uses('denoland/setup-deno', { 'deno-version': deno })),
12
12
  // We need --minimum-dependency-age=0 for functionalscript because we would like to use
13
13
  // the latest version of the package even if it is not yet 24 hours old,
@@ -17,4 +17,4 @@ export const denoSteps = (version) => clean([
17
17
  test({ run: `deno run -A --minimum-dependency-age=0 npm:functionalscript@${version} t` }),
18
18
  test({ run: 'deno install --frozen' }),
19
19
  test({ run: `${denoTest} --coverage && deno coverage --include='.*module\\.f\\.ts'` }),
20
- ]);
20
+ ];
@@ -3,13 +3,13 @@
3
3
  *
4
4
  * @module
5
5
  */
6
- import { pure } 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,14 +22,19 @@ 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
- export const ci = ({ nodeExtra }) => access('Cargo.toml').step(result => {
37
+ export const ci = ({ nodeExtra }) => step(access('Cargo.toml'), result => {
33
38
  const rust = result[0] === 'ok';
34
39
  const jobs = {
35
40
  ...Object.fromEntries(os.flatMap(o => architecture.map(job(rust, nodeExtra(o))(o)))),
@@ -46,7 +51,8 @@ export const ci = ({ nodeExtra }) => access('Cargo.toml').step(result => {
46
51
  },
47
52
  jobs,
48
53
  };
49
- return writeUtf8File('.github/workflows/ci.yml', JSON.stringify(gha, null, ' '))
50
- .step(() => 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);
51
57
  });
52
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;
@@ -5,42 +5,88 @@
5
5
  * @module
6
6
  */
7
7
  import { node } from '../config/module.f.js';
8
- import { clean, install, test, ubuntuArm, uses } from '../common/module.f.js';
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)),
13
15
  test({ run: 'npm ci' }),
14
16
  ];
15
- export const basicNode = (version) => (extra) => clean([
17
+ export const basicNode = (version) => (extra) => [
16
18
  ...nodeInstall(version),
17
19
  ...extra,
18
- ]);
20
+ ];
19
21
  const fjsGlobalInstall = (version) => install({ run: `npm install -g functionalscript@${version}` });
20
22
  export const platformNodeSteps = (version) => [
21
23
  ...nodeInstall(node.default),
22
24
  fjsGlobalInstall(version),
23
25
  test({ run: 'fjs t' }),
24
26
  ];
25
- const node22Steps = (version) => clean([
27
+ const node22Steps = (version) => [
26
28
  ...nodeInstall(node.node22),
27
29
  fjsGlobalInstall(version),
28
30
  test({ run: 'fjs t' }),
29
- ]);
30
- const node24Steps = clean([
31
+ test({ run: 'node --test' }),
32
+ ];
33
+ const node24Steps = [
31
34
  ...nodeInstall(node.node24),
32
35
  test({ run: 'node --test' }),
33
- ]);
34
- const node26Steps = clean([
36
+ ];
37
+ const node26Steps = [
35
38
  ...nodeInstall(node.default),
39
+ test({ run: 'npm run ci-update' }),
40
+ test({ run: 'git add -A && git diff --cached --exit-code' }),
36
41
  test({ run: 'npx tsc' }),
37
42
  test({ run: 'npm run cov' }),
38
43
  test({ run: 'npm pack' }),
39
- ]);
44
+ ];
40
45
  const nodeJob = (steps) => ubuntuArm(steps);
41
46
  export const nodeVersionJobs = (version) => ({
42
- [`node${major(node.node22)}`]: nodeJob(node22Steps(version)),
43
- [`node${major(node.node24)}`]: nodeJob(node24Steps),
44
- [`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),
45
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)}`],
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
+ ]);
46
92
  export const nodeMainSteps = platformNodeSteps;
@@ -12,5 +12,7 @@ export declare const proof: {
12
12
  configuredPackageVersion: () => void;
13
13
  missingPackageJson: () => void;
14
14
  };
15
+ nixFlakes: () => void;
16
+ nixFlakeJob: () => void;
15
17
  ubuntu: () => void;
16
18
  };
package/fjs/ci/proof.f.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { ci, main } from './module.f.js';
2
- import { functionalscript } from './config/module.f.js';
2
+ import { functionalscript, node } from './config/module.f.js';
3
+ import { nodeNixJobs } from './node/module.f.js';
3
4
  import { utf8, utf8ToString } from '../text/module.f.js';
4
5
  import { empty as emptyVec, isVec } from '../types/bit_vec/module.f.js';
5
6
  import { test, ubuntu, parseGitHubAction } from './common/module.f.js';
@@ -19,15 +20,22 @@ const makeState = (rust, packageJson) => ({
19
20
  ...(rust ? { 'Cargo.toml': [emptyVec] } : {}),
20
21
  },
21
22
  });
22
- const workflow = (state) => {
23
- const dotGithub = state.root['.github'];
24
- assert(typeof dotGithub === 'object' && !Array.isArray(dotGithub), dotGithub);
25
- const workflows = dotGithub['workflows'];
26
- assert(typeof workflows === 'object' && !Array.isArray(workflows), workflows);
27
- const file = workflows['ci.yml'];
23
+ const subDir = (dir, name) => {
24
+ const entity = dir[name];
25
+ assert(typeof entity === 'object' && !Array.isArray(entity), entity);
26
+ return entity;
27
+ };
28
+ const text = (dir, name) => {
29
+ const file = dir[name];
28
30
  assert(!(!Array.isArray(file) || file.length === 0), file);
29
- return unwrap(parseGitHubAction(jsonParse(utf8ToString(file[0]))));
31
+ return utf8ToString(file[0]);
30
32
  };
33
+ const path = (dir, names) => names.reduce(subDir, dir);
34
+ const workflow = (state) => {
35
+ const workflows = path(state.root, ['.github', 'workflows']);
36
+ return unwrap(parseGitHubAction(jsonParse(text(workflows, 'ci.yml'))));
37
+ };
38
+ const flake = (state, id) => text(path(state.root, ['nix', 'generated', id]), 'flake.nix');
31
39
  const run = (rust, nodeExtra = () => []) => {
32
40
  const [state, result] = virtual(makeState(rust))(ci({ nodeExtra }));
33
41
  assertEq(result, 0);
@@ -62,6 +70,8 @@ export const proof = {
62
70
  assert(!hasExactRunInJob('wasm', 'cargo test --target wasm32-wasip1-threads --release')(gha), 'unexpected Wasmtime WASM threads release check');
63
71
  assert(hasRunInJob('node22', 'fjs t')(gha), 'expected Node 22 FunctionalScript smoke test');
64
72
  assert(hasRunInJob('node26', 'npm pack')(gha), 'expected Node 26 package check');
73
+ assert(hasRunInJob('node26', 'npm run ci-update')(gha), 'expected Node 26 workflow regeneration');
74
+ assert(hasRunInJob('node26', 'git add -A && git diff --cached --exit-code')(gha), 'expected Node 26 generated-file drift check');
65
75
  assert(!hasRun('npm publish --dry-run')(gha), 'unexpected npm publish dry-run');
66
76
  for (const id of [
67
77
  'ubuntu-intel',
@@ -73,7 +83,6 @@ export const proof = {
73
83
  'node22',
74
84
  'node24',
75
85
  'node26',
76
- 'playwright',
77
86
  ]) {
78
87
  assert(hasRunInJob(id, 'npm ci')(gha), `expected npm ci in ${id}`);
79
88
  }
@@ -133,6 +142,37 @@ export const proof = {
133
142
  assert(hasRun(`npm install -g functionalscript@${functionalscript}`)(gha), 'expected configured-version install');
134
143
  },
135
144
  },
145
+ nixFlakes: () => {
146
+ const [state, result] = virtual(makeState(false))(main());
147
+ assertEq(result, 0);
148
+ for (const { id, packages } of nodeNixJobs) {
149
+ const [nodePackage] = packages;
150
+ assert(flake(state, id).includes(`pkgs.${nodePackage}`), `expected ${nodePackage} in the ${id} flake`);
151
+ }
152
+ },
153
+ nixFlakeJob: () => {
154
+ const gha = run(false);
155
+ const job = gha.jobs['nix-flakes'];
156
+ assert(job !== undefined, 'expected the temporary flake job');
157
+ assert(job.steps.some(step => step.uses?.startsWith('cachix/install-nix-action@') === true), 'expected a pinned Nix installer');
158
+ // Exactly one check per unmigrated flake: none goes unchecked, and no
159
+ // check outlives the flake it was written for. A migrated job checks its
160
+ // own flake by running through it, so it is not covered here.
161
+ assertEq(job.steps.filter(step => step.run !== undefined).length, nodeNixJobs.length);
162
+ for (const { id } of nodeNixJobs) {
163
+ assert(hasRunInJob('nix-flakes', `nix develop ./nix/generated/${id} --command node --version`)(gha), `expected the ${id} flake to be instantiated`);
164
+ }
165
+ // Since the flakes no longer assert their own version, this job is the
166
+ // only place the Nix runtime is tied to the version `setup-node` installs.
167
+ for (const version of [node.node22, node.node24, node.default]) {
168
+ assert(hasRunInJob('nix-flakes', `= v${version}`)(gha), `expected the flake job to check Node ${version}`);
169
+ }
170
+ // The canonical Node jobs keep their current runtime setup until they
171
+ // are migrated one at a time.
172
+ for (const { id } of nodeNixJobs) {
173
+ assert(!hasRunInJob(id, 'nix develop')(gha), `unexpected nix develop in ${id}`);
174
+ }
175
+ },
136
176
  ubuntu: () => {
137
177
  const job = ubuntu([test({ run: 'echo hi' })]);
138
178
  assert(job['runs-on'] !== undefined, 'expected runs-on');
@@ -1,5 +1,5 @@
1
1
  import { errorExit, log } from '../effects/node/module.f.js';
2
- import { pure } from '../effects/module.f.js';
2
+ import { pure, step } from '../effects/module.f.js';
3
3
  import { at, fromEntries } from '../types/object/module.f.js';
4
4
  const helpMeta = { names: ['help', 'h', '?'], description: 'Print this help message' };
5
5
  export const dispatch = (commands) => (options) => {
@@ -23,7 +23,7 @@ export const dispatch = (commands) => (options) => {
23
23
  return dispatch(targetCmd.handler)({ ...options, args: ['help'] });
24
24
  }
25
25
  }
26
- return log(helpText).step(() => pure(0));
26
+ return step(log(helpText), () => pure(0));
27
27
  }
28
28
  const found = at(cmd)(map);
29
29
  if (found === null) {