functionalscript 0.39.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 (88) 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 +1 -1
  8. package/fjs/cas/evo/module.f.js +1 -1
  9. package/fjs/cas/evo/proof.f.js +1 -1
  10. package/fjs/ci/config/module.f.d.ts +7 -4
  11. package/fjs/ci/config/module.f.js +20 -5
  12. package/fjs/ci/module.f.js +12 -5
  13. package/fjs/ci/nix/module.f.d.ts +61 -0
  14. package/fjs/ci/nix/module.f.js +92 -0
  15. package/fjs/ci/nix/proof.f.d.ts +23 -0
  16. package/fjs/ci/nix/proof.f.js +109 -0
  17. package/fjs/ci/node/module.f.d.ts +23 -1
  18. package/fjs/ci/node/module.f.js +47 -3
  19. package/fjs/ci/proof.f.d.ts +2 -0
  20. package/fjs/ci/proof.f.js +47 -9
  21. package/fjs/dev/module.f.d.ts +1 -0
  22. package/fjs/dev/module.f.js +13 -2
  23. package/fjs/dev/update/module.f.d.ts +11 -0
  24. package/fjs/dev/update/module.f.js +20 -0
  25. package/fjs/dev/update/proof.f.d.ts +7 -0
  26. package/fjs/dev/update/proof.f.js +35 -0
  27. package/fjs/djs/ast/module.f.d.ts +47 -0
  28. package/fjs/djs/ast/module.f.js +9 -0
  29. package/fjs/djs/tokenizer/module.f.js +2 -2
  30. package/fjs/djs/tokenizer/proof.f.d.ts +1 -0
  31. package/fjs/djs/tokenizer/proof.f.js +70 -16
  32. package/fjs/effects/node/module.d.ts +3 -3
  33. package/fjs/effects/node/module.f.d.ts +17 -7
  34. package/fjs/effects/node/module.f.js +22 -0
  35. package/fjs/effects/node/module.js +12 -12
  36. package/fjs/effects/node/proof.f.d.ts +1 -0
  37. package/fjs/effects/node/proof.f.js +14 -1
  38. package/fjs/effects/node/virtual/module.f.js +1 -1
  39. package/fjs/emergent_testing/all.test.js +2 -1
  40. package/fjs/emergent_testing/module.f.d.ts +3 -3
  41. package/fjs/emergent_testing/module.f.js +8 -10
  42. package/fjs/emergent_testing/proof.f.js +2 -2
  43. package/fjs/emergent_testing/scenarios/thenable.pass.js +1 -1
  44. package/fjs/fsc/module.f.js +4 -4
  45. package/fjs/fsm/module.f.js +1 -1
  46. package/fjs/js/tokenizer/module.f.d.ts +1 -0
  47. package/fjs/js/tokenizer/module.f.js +13 -6
  48. package/fjs/{cas/mcp → mcp/cas}/module.f.d.ts +3 -25
  49. package/fjs/{cas/mcp → mcp/cas}/module.f.js +18 -58
  50. package/fjs/mcp/evo/module.f.d.ts +27 -0
  51. package/fjs/{cas/evo/mcp → mcp/evo}/module.f.js +9 -9
  52. package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.js +7 -7
  53. package/fjs/mcp/module.f.d.ts +54 -237
  54. package/fjs/mcp/module.f.js +55 -258
  55. package/fjs/mcp/proof.f.d.ts +43 -32
  56. package/fjs/mcp/proof.f.js +508 -200
  57. package/fjs/media/nix/module.f.d.ts +30 -0
  58. package/fjs/media/nix/module.f.js +166 -0
  59. package/fjs/media/nix/proof.f.d.ts +32 -0
  60. package/fjs/media/nix/proof.f.js +127 -0
  61. package/fjs/module.f.js +1 -1
  62. package/fjs/protocol/json_rpc/module.f.d.ts +114 -0
  63. package/fjs/{media/json/rpc → protocol/json_rpc}/module.f.js +3 -3
  64. package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.js +3 -3
  65. package/fjs/protocol/mcp/module.f.d.ts +239 -0
  66. package/fjs/protocol/mcp/module.f.js +272 -0
  67. package/fjs/protocol/mcp/proof.f.d.ts +34 -0
  68. package/fjs/protocol/mcp/proof.f.js +208 -0
  69. package/fjs/{mcp → protocol/mcp}/stdio/module.f.d.ts +5 -5
  70. package/fjs/{mcp → protocol/mcp}/stdio/module.f.js +11 -11
  71. package/fjs/{mcp → protocol/mcp}/stdio/proof.f.js +9 -9
  72. package/fjs/types/range_map/module.f.d.ts +14 -13
  73. package/fjs/types/range_map/module.f.js +18 -13
  74. package/fjs/types/range_map/proof.f.js +26 -39
  75. package/fjs/types/range_set/module.f.d.ts +5 -0
  76. package/fjs/types/range_set/module.f.js +16 -0
  77. package/fjs/types/range_set/proof.f.d.ts +1 -0
  78. package/fjs/types/range_set/proof.f.js +19 -0
  79. package/package.json +5 -5
  80. package/fjs/cas/evo/mcp/module.f.d.ts +0 -27
  81. package/fjs/cas/mcp/proof.f.d.ts +0 -45
  82. package/fjs/cas/mcp/proof.f.js +0 -545
  83. package/fjs/ci/playwright/module.f.d.ts +0 -2
  84. package/fjs/ci/playwright/module.f.js +0 -25
  85. package/fjs/media/json/rpc/module.f.d.ts +0 -114
  86. /package/fjs/{cas/evo/mcp → mcp/evo}/proof.f.d.ts +0 -0
  87. /package/fjs/{media/json/rpc → protocol/json_rpc}/proof.f.d.ts +0 -0
  88. /package/fjs/{mcp → protocol/mcp}/stdio/proof.f.d.ts +0 -0
@@ -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;
@@ -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);
@@ -75,7 +83,6 @@ export const proof = {
75
83
  'node22',
76
84
  'node24',
77
85
  'node26',
78
- 'playwright',
79
86
  ]) {
80
87
  assert(hasRunInJob(id, 'npm ci')(gha), `expected npm ci in ${id}`);
81
88
  }
@@ -135,6 +142,37 @@ export const proof = {
135
142
  assert(hasRun(`npm install -g functionalscript@${functionalscript}`)(gha), 'expected configured-version install');
136
143
  },
137
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
+ },
138
176
  ubuntu: () => {
139
177
  const job = ubuntu([test({ run: 'echo hi' })]);
140
178
  assert(job['runs-on'] !== undefined, 'expected runs-on');
@@ -42,4 +42,5 @@ export type LoadModuleOperations = Access | Import | All | Readdir;
42
42
  export declare const loadModuleMap: (env: Env) => Effect<LoadModuleOperations, ModuleMap>;
43
43
  export declare const proof: {
44
44
  isSourceFile: () => void;
45
+ allFilesSkipsNodeModules: () => void;
45
46
  };
@@ -8,7 +8,8 @@ import { cmp as strCmp } from '../types/string/module.f.js';
8
8
  import { unwrap } from '../types/result/module.f.js';
9
9
  import { pure, step } from '../effects/module.f.js';
10
10
  import { join, relativize, toPosix } from '../path/module.f.js';
11
- import { assert } from '../asserts/module.f.js';
11
+ import { assert, assertEq } from '../asserts/module.f.js';
12
+ import { emptyState, virtual } from '../effects/node/virtual/module.f.js';
12
13
  /**
13
14
  * Returns `true` if the file should be loaded for proof discovery.
14
15
  *
@@ -72,7 +73,7 @@ export const loadModuleMap = (env) => {
72
73
  const s = initCwd === undefined ? '.' : toPosix(initCwd);
73
74
  const prefix = s === '.' ? '' : s;
74
75
  // TODO: there are multiple `all` effects here,
75
- // we should consider optimize them by ALIQ technique or something similar.
76
+ // we should consider optimizing them by ALIQ technique or something similar.
76
77
  // For example, we should be able to write it like `allFiles(s).flatMap(loadFile)`,
77
78
  // then an effect runner can batch all file loading operations together.
78
79
  const x0 = step(allFiles(s, shouldLoad), files => all(...files.map(loadFile)));
@@ -90,4 +91,14 @@ export const proof = {
90
91
  assert(!isSourceFile('readme.md'));
91
92
  assert(!isSourceFile('module.json'));
92
93
  },
94
+ allFilesSkipsNodeModules: () => {
95
+ // `node_modules` is skipped without descending into it, even though
96
+ // it contains a file that would otherwise match the predicate.
97
+ const root = {
98
+ 'node_modules': { 'pkg.f.ts': [] },
99
+ 'a.f.ts': [],
100
+ };
101
+ const [, result] = virtual({ ...emptyState, root })(allFiles('.', shouldLoad));
102
+ assertEq(result.join(','), './a.f.ts');
103
+ },
93
104
  };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Synchronizes local development configuration generated from canonical repository files.
3
+ *
4
+ * @module
5
+ */
6
+ import { type Effect } from '../../effects/module.f.ts';
7
+ import { type Mkdir, type NodeProgram, type ReadFile, type WriteFile } from '../../effects/node/module.f.ts';
8
+ /** Regenerates VS Code's local MCP configuration from the canonical Copilot configuration. */
9
+ export declare const syncMcp: () => Effect<Mkdir | ReadFile | WriteFile, void>;
10
+ /** Runs all local development configuration generators. */
11
+ export declare const main: NodeProgram;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Synchronizes local development configuration generated from canonical repository files.
3
+ *
4
+ * @module
5
+ */
6
+ import { history, historyStep, mapStep, step } from '../../effects/module.f.js';
7
+ import { mkdir, readUtf8File, writeUtf8File } from '../../effects/node/module.f.js';
8
+ import { unwrap } from '../../types/result/module.f.js';
9
+ const source = '.copilot/mcp.json';
10
+ const targetDirectory = '.vscode';
11
+ const target = '.vscode/mcp.json';
12
+ /** Regenerates VS Code's local MCP configuration from the canonical Copilot configuration. */
13
+ export const syncMcp = () => {
14
+ const sourceText = history(mapStep(readUtf8File(source), unwrap));
15
+ const targetDirectoryReady = historyStep(sourceText, () => mapStep(mkdir(targetDirectory, { recursive: true }), unwrap));
16
+ const targetWritten = step(targetDirectoryReady, ([, text]) => writeUtf8File(target, text));
17
+ return mapStep(targetWritten, unwrap);
18
+ };
19
+ /** Runs all local development configuration generators. */
20
+ export const main = () => mapStep(syncMcp(), () => 0);
@@ -0,0 +1,7 @@
1
+ export declare const proof: {
2
+ syncMcp: () => void;
3
+ main: () => void;
4
+ throw: {
5
+ missingSource: () => readonly [import("../../effects/node/virtual/module.f.ts").State, void];
6
+ };
7
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Proofs for local development configuration generation.
3
+ *
4
+ * @module
5
+ */
6
+ import { assert, assertEq } from '../../asserts/module.f.js';
7
+ import { utf8 } from '../../text/module.f.js';
8
+ import { readUtf8File } from '../../effects/node/module.f.js';
9
+ import { defaultNodeProgramOptions, emptyState, virtual } from '../../effects/node/virtual/module.f.js';
10
+ import { main, syncMcp } from './module.f.js';
11
+ import { step } from '../../effects/module.f.js';
12
+ const mcp = '{"servers":{}}';
13
+ const initial = {
14
+ ...emptyState,
15
+ root: {
16
+ '.copilot': {
17
+ 'mcp.json': [utf8(mcp)],
18
+ },
19
+ },
20
+ };
21
+ export const proof = {
22
+ syncMcp: () => {
23
+ const generatedMcp = step(syncMcp(), () => readUtf8File('.vscode/mcp.json'));
24
+ const [, [tag, result]] = virtual(initial)(generatedMcp);
25
+ assert(tag === 'ok', result);
26
+ assertEq(result, mcp);
27
+ },
28
+ main: () => {
29
+ const [, result] = virtual(initial)(main(defaultNodeProgramOptions));
30
+ assertEq(result, 0);
31
+ },
32
+ throw: {
33
+ missingSource: () => virtual(emptyState)(syncMcp()),
34
+ },
35
+ };
@@ -4,12 +4,59 @@
4
4
  * @module
5
5
  */
6
6
  import type { Primitive, Array, Unknown } from '../module.f.ts';
7
+ /**
8
+ * A parsed DJS module: its imported module specifiers, in source order, and
9
+ * its body.
10
+ *
11
+ * The specifier list indexes `['aref', i]`.
12
+ */
7
13
  export type AstModule = [readonly string[], AstBody];
14
+ /** A value in a module body: a primitive, a reference, an array, or an object. */
8
15
  export type AstConst = Primitive | AstModuleRef | AstArray | AstObject;
16
+ /**
17
+ * A reference to a value defined outside this `AstConst`.
18
+ *
19
+ * - `['aref', i]` — the `i`-th argument of the body, i.e. the `i`-th imported
20
+ * module of the enclosing `AstModule`.
21
+ * - `['cref', i]` — the `i`-th entry of the enclosing `AstBody`.
22
+ *
23
+ * Both indices are absolute and zero-based, **not** offsets from the
24
+ * referencing entry: in the body `[a, b, ['cref', 0]]` the reference resolves
25
+ * to `a`, not to the nearest preceding entry `b`.
26
+ *
27
+ * A `cref` index must be smaller than the index of the entry holding it —
28
+ * `run` evaluates a body left to right, so a reference to the current or a
29
+ * later entry is unsatisfiable. It is not rejected: it resolves to the most
30
+ * recently evaluated entry instead.
31
+ */
9
32
  export type AstModuleRef = ['aref' | 'cref', number];
33
+ /** An array value; its elements are evaluated in order. */
10
34
  export type AstArray = ['array', readonly AstConst[]];
35
+ /** An object value, keyed by property name. */
11
36
  export type AstObject = {
12
37
  readonly [k in string]?: AstConst;
13
38
  };
39
+ /**
40
+ * The constants of a module body, in declaration order. The **last** entry is
41
+ * the value the module yields; the preceding entries exist to be named by
42
+ * `['cref', i]`.
43
+ *
44
+ * A body describes the function
45
+ *
46
+ * ```js
47
+ * (...args) => { const c0 = ...; const c1 = ...; return <last> }
48
+ * ```
49
+ *
50
+ * where `args` are the imported modules.
51
+ */
14
52
  export type AstBody = readonly AstConst[];
53
+ /**
54
+ * Evaluates a module body against its imported modules and returns the value
55
+ * the module yields — the last entry of the body.
56
+ *
57
+ * Entries are evaluated left to right, so a `cref` resolves to an already
58
+ * evaluated entry. A reference is shared, not copied: two properties holding
59
+ * the same `['cref', i]` deserialize to the same object, which is what lets a
60
+ * DJS module denote a graph rather than a tree.
61
+ */
15
62
  export declare const run: (body: AstBody) => (args: Array) => Unknown;