functionalscript 0.40.0 → 0.42.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 (61) hide show
  1. package/fjs/cas/evo/module.f.d.ts +27 -7
  2. package/fjs/cas/evo/module.f.js +42 -9
  3. package/fjs/cas/evo/proof.f.d.ts +4 -0
  4. package/fjs/cas/evo/proof.f.js +81 -0
  5. package/fjs/ci/config/module.f.d.ts +5 -5
  6. package/fjs/ci/config/module.f.js +5 -5
  7. package/fjs/ci/deno/module.f.d.ts +8 -0
  8. package/fjs/ci/deno/module.f.js +9 -1
  9. package/fjs/ci/deno/proof.f.d.ts +5 -0
  10. package/fjs/ci/deno/proof.f.js +26 -0
  11. package/fjs/ci/node/proof.f.d.ts +3 -0
  12. package/fjs/ci/node/proof.f.js +17 -0
  13. package/fjs/ci/proof.f.js +3 -2
  14. package/fjs/dev/module.f.d.ts +15 -4
  15. package/fjs/dev/module.f.js +38 -7
  16. package/fjs/dev/package_json/module.f.js +7 -4
  17. package/fjs/dev/proof.f.js +10 -1
  18. package/fjs/djs/parser/module.f.js +13 -3
  19. package/fjs/djs/parser/proof.f.d.ts +1 -0
  20. package/fjs/djs/parser/proof.f.js +26 -0
  21. package/fjs/djs/tokenizer/proof.f.js +11 -4
  22. package/fjs/effects/module.f.d.ts +20 -0
  23. package/fjs/effects/module.f.js +25 -1
  24. package/fjs/effects/proof.f.d.ts +5 -0
  25. package/fjs/effects/proof.f.js +22 -0
  26. package/fjs/emergent_testing/proof.f.js +20 -3
  27. package/fjs/js/tokenizer/proof.f.js +7 -0
  28. package/fjs/mcp/cas/module.f.js +4 -1
  29. package/fjs/mcp/evo/module.f.d.ts +7 -2
  30. package/fjs/mcp/evo/module.f.js +10 -5
  31. package/fjs/mcp/evo/proof.f.d.ts +1 -0
  32. package/fjs/mcp/evo/proof.f.js +22 -1
  33. package/fjs/mcp/module.f.d.ts +1 -1
  34. package/fjs/mcp/module.f.js +1 -1
  35. package/fjs/mcp/proof.f.js +37 -18
  36. package/fjs/media/json/module.f.d.ts +15 -1
  37. package/fjs/media/json/module.f.js +17 -1
  38. package/fjs/media/json/parser/module.f.js +13 -3
  39. package/fjs/media/json/parser/proof.f.d.ts +1 -0
  40. package/fjs/media/json/parser/proof.f.js +37 -1
  41. package/fjs/media/json/proof.f.d.ts +4 -0
  42. package/fjs/media/json/proof.f.js +14 -2
  43. package/fjs/media/json/serializer/module.f.d.ts +4 -0
  44. package/fjs/media/json/serializer/module.f.js +39 -2
  45. package/fjs/media/json/serializer/proof.f.js +32 -8
  46. package/fjs/media/module.f.d.ts +92 -10
  47. package/fjs/media/module.f.js +73 -8
  48. package/fjs/media/proof.f.d.ts +10 -0
  49. package/fjs/media/proof.f.js +79 -10
  50. package/fjs/media/revision/module.f.d.ts +9 -1
  51. package/fjs/media/revision/module.f.js +14 -5
  52. package/fjs/protocol/mcp/stdio/module.f.js +2 -5
  53. package/fjs/text/utf16/module.f.d.ts +24 -0
  54. package/fjs/text/utf16/module.f.js +24 -0
  55. package/fjs/text/utf16/proof.f.d.ts +1 -0
  56. package/fjs/text/utf16/proof.f.js +10 -1
  57. package/fjs/types/rtti/validate/proof.f.d.ts +2 -0
  58. package/fjs/types/rtti/validate/proof.f.js +58 -0
  59. package/nanvm-lib/tests/proof.f.d.ts +9 -0
  60. package/nanvm-lib/tests/proof.f.js +58 -7
  61. package/package.json +2 -2
@@ -17,7 +17,10 @@
17
17
  * subject (see [`fjs/media/revision/README.md`](../../media/revision/README.md)).
18
18
  * `Cache` therefore tracks, per subject, every revision hash seen and every
19
19
  * hash referenced as somebody's parent; heads are the set difference between
20
- * the two, computed at read time ({@link headsOf}). Storing both sets rather
20
+ * the two, computed at read time ({@link headsOf}). Alongside them it records
21
+ * which of the seen revisions are `archived`, so {@link Evo.list} can classify
22
+ * a subject as active or archived from its heads' flags
23
+ * ({@link subjectListed}) without touching the store. Storing both sets rather
21
24
  * than a running head list is what makes folding revisions truly order
22
25
  * independent: `cas.list()` (used by {@link buildCache} to scan an existing
23
26
  * store) returns hashes in hash order, not revision ancestry, so a child can
@@ -97,14 +100,22 @@ export type RevisionData = {
97
100
  readonly generation?: number | undefined;
98
101
  };
99
102
  /**
100
- * Per-subject bookkeeping: every revision hash seen for the subject, and
101
- * every hash any of those revisions names as a parent. See the module doc
102
- * for why both sets are kept (rather than a running head list) and
103
- * {@link headsOf} for how heads are derived from them.
103
+ * Per-subject bookkeeping: every revision hash seen for the subject, every
104
+ * hash any of those revisions names as a parent, and which of the seen
105
+ * revisions are `archived`. See the module doc for why the first two sets are
106
+ * kept (rather than a running head list), {@link headsOf} for how heads are
107
+ * derived from them, and {@link subjectListed} for how `archived` classifies a
108
+ * subject once its heads are known.
109
+ *
110
+ * `archived` is keyed by revision hash, not by subject, for the same reason
111
+ * heads are computed at read time: which revisions are heads is only known
112
+ * once the whole store has been folded in, so a per-subject archived flag
113
+ * would have to be revised every time a later fold changes the head set.
104
114
  */
105
115
  export type SubjectState = {
106
116
  readonly hashes: readonly Hash[];
107
117
  readonly parents: readonly Hash[];
118
+ readonly archived: readonly Hash[];
108
119
  };
109
120
  /** In-memory index: subject → its {@link SubjectState}. */
110
121
  export type Cache = {
@@ -182,8 +193,17 @@ export declare const addRevision: <O extends Operation>(cas: Cas<O>) => (cacheKe
182
193
  export declare const readRevision: <O extends Operation>(cas: Cas<O>) => (hash: Hash) => Effect<O, Result<RevisionData, string>>;
183
194
  /** The Evo API described in `fjs/cas/evo/README.md`, bound to a `Cas<O>` and its cache slot. */
184
195
  export type Evo<O extends Operation> = {
185
- /** Returns every subject with at least one stored revision. */
186
- readonly list: () => Effect<MemOp, readonly Subject[]>;
196
+ /**
197
+ * Returns the subjects matching a status filter: the active ones by
198
+ * default, the archived ones when `archived` is `true`. A subject's status
199
+ * is derived from its current heads — see {@link subjectListed}, which
200
+ * also explains why a subject with no current heads is in neither result.
201
+ *
202
+ * There is deliberately no all-subjects mode: nothing needs one yet, and
203
+ * adding it later is a compatible extension of this parameter, while
204
+ * removing it would not be.
205
+ */
206
+ readonly list: (archived?: true) => Effect<MemOp, readonly Subject[]>;
187
207
  /** Returns the current head hashes of `subject` (empty if unknown). */
188
208
  readonly head: (subject: Subject) => Effect<MemOp, readonly Hash[]>;
189
209
  /** Adds a new head; see {@link addRevision}. */
@@ -17,7 +17,10 @@
17
17
  * subject (see [`fjs/media/revision/README.md`](../../media/revision/README.md)).
18
18
  * `Cache` therefore tracks, per subject, every revision hash seen and every
19
19
  * hash referenced as somebody's parent; heads are the set difference between
20
- * the two, computed at read time ({@link headsOf}). Storing both sets rather
20
+ * the two, computed at read time ({@link headsOf}). Alongside them it records
21
+ * which of the seen revisions are `archived`, so {@link Evo.list} can classify
22
+ * a subject as active or archived from its heads' flags
23
+ * ({@link subjectListed}) without touching the store. Storing both sets rather
21
24
  * than a running head list is what makes folding revisions truly order
22
25
  * independent: `cas.list()` (used by {@link buildCache} to scan an existing
23
26
  * store) returns hashes in hash order, not revision ancestry, so a child can
@@ -58,7 +61,7 @@ import { isNotFound } from '../../effects/node/module.f.js';
58
61
  export const emptyCache = { bySubject: {} };
59
62
  /** Canonical JSON encoder for a `Revision` — key order carries no meaning for detection. */
60
63
  const toJson = stringify(identity);
61
- const emptySubjectState = { hashes: [], parents: [] };
64
+ const emptySubjectState = { hashes: [], parents: [], archived: [] };
62
65
  /** Adds every item of `items` to `set` that isn't already there, preserving `set`'s existing order. */
63
66
  const union = (set) => (items) => items.reduce((acc, h) => acc.includes(h) ? acc : [...acc, h], set);
64
67
  /**
@@ -76,12 +79,37 @@ const union = (set) => (items) => items.reduce((acc, h) => acc.includes(h) ? acc
76
79
  const canonicalHash = (h) => vecToCBase32(unwrap(cBase32ToVec(h)));
77
80
  /** A subject's current heads: revision hashes seen that no other revision of the same subject names as a parent. */
78
81
  const headsOf = (state) => state.hashes.filter(h => !state.parents.includes(h));
82
+ /**
83
+ * Whether a subject in `state` belongs in {@link Evo.list}'s result for the
84
+ * given `archived` filter — the subject-level status derived from its
85
+ * revision-level `archived` flags:
86
+ *
87
+ * - **active** — at least one current head is not archived. This is the
88
+ * default result set (`archived` omitted).
89
+ * - **archived** — the subject has at least one current head and every one of
90
+ * them is archived (`archived: true`).
91
+ *
92
+ * Concurrent heads can disagree, and the two rules resolve that the same way:
93
+ * one unarchived head keeps the whole subject active, because a subject is
94
+ * only done evolving when nothing left to build on remains. A subject with no
95
+ * current heads is neither active nor archived and appears in no result — the
96
+ * status is a statement about heads, and there is nothing to state. That case
97
+ * needs the explicit `heads.length` test only in the archived branch, since
98
+ * "every head is archived" is vacuously true of no heads at all.
99
+ */
100
+ const subjectListed = (archived) => (state) => {
101
+ const heads = headsOf(state);
102
+ const unarchived = heads.filter(h => !state.archived.includes(h));
103
+ return archived === undefined
104
+ ? unarchived.length !== 0
105
+ : heads.length !== 0 && unarchived.length === 0;
106
+ };
79
107
  /**
80
108
  * Folds one more stored revision into `cache`: `hash` joins its subject's
81
- * `hashes` set, and `revision.parents` (canonicalized, see
82
- * {@link canonicalHash}) join its `parents` set. Order independent (see the
83
- * module doc) used both for a full-store scan and for a single
84
- * incremental `add`.
109
+ * `hashes` set, `revision.parents` (canonicalized, see {@link canonicalHash})
110
+ * join its `parents` set, and `hash` also joins the `archived` set when the
111
+ * revision carries `archived: true`. Order independent (see the module doc)
112
+ * used both for a full-store scan and for a single incremental `add`.
85
113
  *
86
114
  * Looks `revision.subject` up via {@link at} (own-property only), not plain
87
115
  * bracket indexing: a subject is an arbitrary caller-supplied string
@@ -96,6 +124,7 @@ const addRevisionToCache = (hash, revision) => (cache) => {
96
124
  const state = {
97
125
  hashes: union(existing.hashes)([hash]),
98
126
  parents: union(existing.parents)(revision.parents.map(canonicalHash)),
127
+ archived: union(existing.archived)(revision.archived === undefined ? [] : [hash]),
99
128
  };
100
129
  return { bySubject: { ...cache.bySubject, [revision.subject]: state } };
101
130
  };
@@ -407,9 +436,13 @@ export const readRevision = (cas) => (hash) => {
407
436
  };
408
437
  /** Builds the {@link Evo} API over `cas`, backed by the cache at `cacheKey` (see {@link initEvo}). */
409
438
  export const evo = (cas) => (cacheKey) => ({
410
- list: () => eff(read(cacheKey))
411
- .step(cache => pure(definedEntries(cache.bySubject).map(([subject]) => subject)))
412
- .value,
439
+ list: archived => {
440
+ const listed = subjectListed(archived);
441
+ return eff(read(cacheKey))
442
+ .step(cache => pure(definedEntries(cache.bySubject)
443
+ .flatMap(([subject, state]) => listed(state) ? [subject] : [])))
444
+ .value;
445
+ },
413
446
  head: subject => eff(read(cacheKey))
414
447
  .step(cache => {
415
448
  const state = at(subject)(cache.bySubject);
@@ -9,6 +9,10 @@ export declare const proof: {
9
9
  buildCacheOrderIndependentWhenChildScannedBeforeParent: () => void;
10
10
  buildCacheCanonicalizesNonCanonicalParentHashes: () => void;
11
11
  addRevisionBuildsHeadsAcrossChainAndFork: () => void;
12
+ listPartitionsSubjectsByHeadArchivedFlag: () => void;
13
+ listTreatsDisagreeingHeadsAsActive: () => void;
14
+ listIgnoresArchivedRevisionsThatAreNoLongerHeads: () => void;
15
+ listExcludesSubjectWithNoCurrentHeads: () => void;
12
16
  addRevisionIdempotentOnDuplicateContent: () => void;
13
17
  addRevisionCanonicalizesParentSpellingBeforeSerializing: () => void;
14
18
  addRevisionResolvesSubjectFromSingleParent: () => void;
@@ -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: () => {
@@ -18,19 +18,19 @@ export declare const images: {
18
18
  readonly arm: 'windows-11-arm';
19
19
  };
20
20
  };
21
- export declare const functionalscript: '0.38.0';
21
+ export declare const functionalscript: '0.41.0';
22
22
  export declare const bun = "1.3.14";
23
23
  export declare const deno = "2.9.4";
24
24
  export declare const node: {
25
- readonly default: '26.5.1';
26
- readonly node22: '22.23.1';
25
+ readonly default: '26.6.0';
26
+ readonly node22: '22.23.2';
27
27
  readonly node24: '24.18.0';
28
28
  };
29
29
  export declare const nixpkgs: {
30
30
  readonly ref: 'nixos-26.05';
31
- readonly commit: '21ea275a7c46aef9d4d6ddc962e6d562e9d94183';
31
+ readonly commit: '04607e1165ac22c5fde6dcc54c9e0b3c0487c555';
32
32
  };
33
- export declare const wasmtime = "47.0.2";
33
+ export declare const wasmtime = "47.0.3";
34
34
  export declare const wasmer = "7.2.1";
35
35
  export declare const actions: {
36
36
  readonly 'actions/checkout': 'v7.0.1';
@@ -23,7 +23,7 @@ export const images = {
23
23
  // published FunctionalScript release; do not tie it to package.json's current
24
24
  // in-repo version.
25
25
  // https://www.npmjs.com/package/functionalscript
26
- export const functionalscript = '0.38.0';
26
+ export const functionalscript = '0.41.0';
27
27
  // https://bun.sh/
28
28
  export const bun = '1.3.14';
29
29
  // https://deno.com/
@@ -36,8 +36,8 @@ export const deno = '2.9.4';
36
36
  // it offers rather than the latest release.
37
37
  // https://nodejs.org/en/download
38
38
  export const node = {
39
- default: '26.5.1',
40
- node22: '22.23.1',
39
+ default: '26.6.0',
40
+ node22: '22.23.2',
41
41
  node24: '24.18.0',
42
42
  };
43
43
  // Official Nixpkgs snapshot used by the generated CI flakes. `ref` is the
@@ -47,10 +47,10 @@ export const node = {
47
47
  // https://channels.nixos.org/nixos-26.05/git-revision
48
48
  export const nixpkgs = {
49
49
  ref: 'nixos-26.05',
50
- commit: '21ea275a7c46aef9d4d6ddc962e6d562e9d94183',
50
+ commit: '04607e1165ac22c5fde6dcc54c9e0b3c0487c555',
51
51
  };
52
52
  // https://github.com/bytecodealliance/wasmtime/releases
53
- export const wasmtime = '47.0.2';
53
+ export const wasmtime = '47.0.3';
54
54
  // https://github.com/wasmerio/wasmer/releases
55
55
  export const wasmer = '7.2.1';
56
56
  // GitHub Action versions used by CI step builders. The key is the action
@@ -1,2 +1,10 @@
1
1
  import { type MetaStep } from '../common/module.f.ts';
2
+ /**
3
+ * The regular expression selecting FunctionalScript implementation modules for
4
+ * Deno coverage. Both authored extensions are included so a module migrated
5
+ * from `.f.ts` to `.f.mjs` stays in the report. Keep it semantically equal to
6
+ * the `--test-coverage-include` list in `package.json` and to the `cov` task in
7
+ * `deno.json`.
8
+ */
9
+ export declare const coverageInclude: '.*module\\.f\\.(ts|mjs)';
2
10
  export declare const denoSteps: (version: string) => readonly MetaStep[];
@@ -7,6 +7,14 @@
7
7
  import { deno } from '../config/module.f.js';
8
8
  import { install, test, uses } from '../common/module.f.js';
9
9
  const denoTest = 'deno test --allow-read --allow-env --allow-sys';
10
+ /**
11
+ * The regular expression selecting FunctionalScript implementation modules for
12
+ * Deno coverage. Both authored extensions are included so a module migrated
13
+ * from `.f.ts` to `.f.mjs` stays in the report. Keep it semantically equal to
14
+ * the `--test-coverage-include` list in `package.json` and to the `cov` task in
15
+ * `deno.json`.
16
+ */
17
+ export const coverageInclude = '.*module\\.f\\.(ts|mjs)';
10
18
  export const denoSteps = (version) => [
11
19
  install(uses('denoland/setup-deno', { 'deno-version': deno })),
12
20
  // We need --minimum-dependency-age=0 for functionalscript because we would like to use
@@ -16,5 +24,5 @@ export const denoSteps = (version) => [
16
24
  install({ run: `deno install -g -A --minimum-dependency-age=0 npm:functionalscript@${version}` }),
17
25
  test({ run: `deno run -A --minimum-dependency-age=0 npm:functionalscript@${version} t` }),
18
26
  test({ run: 'deno install --frozen' }),
19
- test({ run: `${denoTest} --coverage && deno coverage --include='.*module\\.f\\.ts'` }),
27
+ test({ run: `${denoTest} --coverage && deno coverage --include='${coverageInclude}'` }),
20
28
  ];
@@ -0,0 +1,5 @@
1
+ export declare const proof: {
2
+ coverageInclude: () => void;
3
+ coverageStep: () => void;
4
+ installsPinnedVersion: () => void;
5
+ };
@@ -0,0 +1,26 @@
1
+ import { coverageInclude, denoSteps } from './module.f.js';
2
+ import { toSteps } from '../common/module.f.js';
3
+ import { assert, assertEq } from '../../asserts/module.f.js';
4
+ const coverageRuns = (version) => toSteps(denoSteps(version))
5
+ .flatMap(s => s.run !== undefined && s.run.includes('deno coverage') ? [s.run] : []);
6
+ export const proof = {
7
+ // A regression guard: dropping either authored implementation extension
8
+ // from the Deno coverage filter silently removes those modules from the
9
+ // CI coverage report while CI still passes.
10
+ coverageInclude: () => {
11
+ assertEq(coverageInclude, '.*module\\.f\\.(ts|mjs)');
12
+ },
13
+ coverageStep: () => {
14
+ const runs = coverageRuns('0.0.0');
15
+ assertEq(runs.length, 1);
16
+ const [run] = runs;
17
+ assert(run !== undefined);
18
+ assert(run.includes(`deno coverage --include='${coverageInclude}'`));
19
+ },
20
+ installsPinnedVersion: () => {
21
+ const runs = toSteps(denoSteps('1.2.3'))
22
+ .flatMap(s => s.run !== undefined && s.run.includes('npm:functionalscript@') ? [s.run] : []);
23
+ assertEq(runs.length, 2);
24
+ assert(runs.every(r => r.includes('npm:functionalscript@1.2.3')));
25
+ },
26
+ };
@@ -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
+ };
package/fjs/ci/proof.f.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ci, main } from './module.f.js';
2
2
  import { functionalscript, node } from './config/module.f.js';
3
3
  import { nodeNixJobs } from './node/module.f.js';
4
+ import { coverageInclude } from './deno/module.f.js';
4
5
  import { utf8, utf8ToString } from '../text/module.f.js';
5
6
  import { empty as emptyVec, isVec } from '../types/bit_vec/module.f.js';
6
7
  import { test, ubuntu, parseGitHubAction } from './common/module.f.js';
@@ -33,7 +34,7 @@ const text = (dir, name) => {
33
34
  const path = (dir, names) => names.reduce(subDir, dir);
34
35
  const workflow = (state) => {
35
36
  const workflows = path(state.root, ['.github', 'workflows']);
36
- return unwrap(parseGitHubAction(jsonParse(text(workflows, 'ci.yml'))));
37
+ return unwrap(parseGitHubAction(unwrap(jsonParse(text(workflows, 'ci.yml')))));
37
38
  };
38
39
  const flake = (state, id) => text(path(state.root, ['nix', 'generated', id]), 'flake.nix');
39
40
  const run = (rust, nodeExtra = () => []) => {
@@ -132,7 +133,7 @@ export const proof = {
132
133
  assert(hasRun(`deno install -g -A --minimum-dependency-age=0 npm:functionalscript@${functionalscript}`)(gha), 'expected configured-version deno install cache');
133
134
  assert(hasRun('deno install --frozen')(gha), 'expected deno lock install');
134
135
  assert(hasRun(`deno run -A --minimum-dependency-age=0 npm:functionalscript@${functionalscript} t`)(gha), 'expected configured-version deno install');
135
- assert(hasRun("deno test --allow-read --allow-env --allow-sys --coverage && deno coverage --include='.*module\\.f\\.ts'")(gha), 'expected limited-permission deno coverage');
136
+ assert(hasRun(`deno test --allow-read --allow-env --allow-sys --coverage && deno coverage --include='${coverageInclude}'`)(gha), 'expected limited-permission deno coverage');
136
137
  assert(hasRun(`bun install -g functionalscript@${functionalscript}`)(gha), 'expected configured-version bun cache');
137
138
  assert(hasRun('bun install --frozen-lockfile')(gha), 'expected bun lock install');
138
139
  assert(hasRun(`bunx functionalscript@${functionalscript} t`)(gha), 'expected configured-version bun install');
@@ -14,10 +14,19 @@ export type ModuleMap = StringMap<string, Module>;
14
14
  /**
15
15
  * Returns `true` if the file should be loaded for proof discovery.
16
16
  *
17
- * All FunctionalScript modules (`.f.ts` / `.f.js`) are safe to bulk-load by
18
- * construction — they have no import side effects. For vanilla TS/JS the
19
- * load gate stays opt-in by filename: any file ending in `proof.ts`,
20
- * `proof.js`, `proof.mts`, or `proof.mjs` is included.
17
+ * Two symmetrical rules, each covering all four TS/JS module extensions:
18
+ *
19
+ * - **FunctionalScript modules** anything ending in `.f.ts`, `.f.mts`,
20
+ * `.f.js`, or `.f.mjs`. They are safe to bulk-load by construction, since
21
+ * they have no import side effects, so the whole module is loaded and its
22
+ * internal `proof` export (if any) is discovered. `.f.ts` and `.f.mjs` are
23
+ * the authored extensions; `.f.js` is generated from `.f.ts`.
24
+ * - **Impure JavaScript/TypeScript proofs** — anything ending in `proof.ts`,
25
+ * `proof.mts`, `proof.js`, or `proof.mjs`. Outside FunctionalScript a module
26
+ * may have import side effects, so the load gate stays opt-in by filename.
27
+ *
28
+ * A `proof.f.mts` / `proof.f.mjs` matches the FunctionalScript rule by its
29
+ * `.f.` infix, not the vanilla proof rule.
21
30
  *
22
31
  * Whether a loaded module actually _contains_ a proof is determined at
23
32
  * runtime by checking for an exported `proof` property.
@@ -42,5 +51,7 @@ export type LoadModuleOperations = Access | Import | All | Readdir;
42
51
  export declare const loadModuleMap: (env: Env) => Effect<LoadModuleOperations, ModuleMap>;
43
52
  export declare const proof: {
44
53
  isSourceFile: () => void;
54
+ allFilesFindsFunctionalScript: () => void;
45
55
  allFilesSkipsNodeModules: () => void;
56
+ loadModuleMapDefaultsToCwdWhenInitCwdUnset: () => void;
46
57
  };
@@ -13,17 +13,27 @@ import { emptyState, virtual } from '../effects/node/virtual/module.f.js';
13
13
  /**
14
14
  * Returns `true` if the file should be loaded for proof discovery.
15
15
  *
16
- * All FunctionalScript modules (`.f.ts` / `.f.js`) are safe to bulk-load by
17
- * construction — they have no import side effects. For vanilla TS/JS the
18
- * load gate stays opt-in by filename: any file ending in `proof.ts`,
19
- * `proof.js`, `proof.mts`, or `proof.mjs` is included.
16
+ * Two symmetrical rules, each covering all four TS/JS module extensions:
17
+ *
18
+ * - **FunctionalScript modules** anything ending in `.f.ts`, `.f.mts`,
19
+ * `.f.js`, or `.f.mjs`. They are safe to bulk-load by construction, since
20
+ * they have no import side effects, so the whole module is loaded and its
21
+ * internal `proof` export (if any) is discovered. `.f.ts` and `.f.mjs` are
22
+ * the authored extensions; `.f.js` is generated from `.f.ts`.
23
+ * - **Impure JavaScript/TypeScript proofs** — anything ending in `proof.ts`,
24
+ * `proof.mts`, `proof.js`, or `proof.mjs`. Outside FunctionalScript a module
25
+ * may have import side effects, so the load gate stays opt-in by filename.
26
+ *
27
+ * A `proof.f.mts` / `proof.f.mjs` matches the FunctionalScript rule by its
28
+ * `.f.` infix, not the vanilla proof rule.
20
29
  *
21
30
  * Whether a loaded module actually _contains_ a proof is determined at
22
31
  * runtime by checking for an exported `proof` property.
23
32
  */
24
- export const shouldLoad = (s) => s.endsWith('.f.ts') || s.endsWith('.f.js') ||
25
- s.endsWith('proof.ts') || s.endsWith('proof.js') ||
26
- s.endsWith('proof.mts') || s.endsWith('proof.mjs');
33
+ export const shouldLoad = (s) => s.endsWith('.f.ts') || s.endsWith('.f.mts') ||
34
+ s.endsWith('.f.js') || s.endsWith('.f.mjs') ||
35
+ s.endsWith('proof.ts') || s.endsWith('proof.mts') ||
36
+ s.endsWith('proof.js') || s.endsWith('proof.mjs');
27
37
  const isSourceFile = (path) => path.endsWith('.js') || path.endsWith('.ts') || path.endsWith('.mts') || path.endsWith('.mjs');
28
38
  const allFiles = (s, predicate) => {
29
39
  const load = (p) => {
@@ -91,6 +101,19 @@ export const proof = {
91
101
  assert(!isSourceFile('readme.md'));
92
102
  assert(!isSourceFile('module.json'));
93
103
  },
104
+ allFilesFindsFunctionalScript: () => {
105
+ // Every FunctionalScript extension is discovered, so a module migrated
106
+ // from `.f.ts` to `.f.mjs` keeps its proofs. An ordinary `.mjs` is
107
+ // still skipped.
108
+ const root = {
109
+ 'a.f.ts': [],
110
+ 'b.f.mjs': [],
111
+ 'c.f.mts': [],
112
+ 'd.mjs': [],
113
+ };
114
+ const [, result] = virtual({ ...emptyState, root })(allFiles('.', shouldLoad));
115
+ assertEq(result.join(','), './a.f.ts,./b.f.mjs,./c.f.mts');
116
+ },
94
117
  allFilesSkipsNodeModules: () => {
95
118
  // `node_modules` is skipped without descending into it, even though
96
119
  // it contains a file that would otherwise match the predicate.
@@ -101,4 +124,12 @@ export const proof = {
101
124
  const [, result] = virtual({ ...emptyState, root })(allFiles('.', shouldLoad));
102
125
  assertEq(result.join(','), './a.f.ts');
103
126
  },
127
+ loadModuleMapDefaultsToCwdWhenInitCwdUnset: () => {
128
+ // With no `INIT_CWD` (e.g. `fjs t` invoked outside `npm run`), `env`
129
+ // lookup returns `undefined` and discovery falls back to `.`, so
130
+ // every path keeps its own `./`-prefix instead of having one stripped.
131
+ const root = { 'a.f.ts': () => ({}) };
132
+ const [, result] = virtual({ ...emptyState, root })(loadModuleMap({}));
133
+ assertEq(Object.keys(result).join(','), './a.f.ts');
134
+ },
104
135
  };
@@ -1,6 +1,10 @@
1
- import { parse as jsonParse } from '../../media/json/parser/module.f.js';
2
- import { tokenize as jsonTokenize } from '../../media/json/tokenizer/module.f.js';
3
- import { stringToList } from '../../text/utf16/module.f.js';
1
+ /**
2
+ * Helpers for reading package.json-style metadata without losing unrelated
3
+ * fields before write-back.
4
+ *
5
+ * @module
6
+ */
7
+ import { parse as parseJsonText } from '../../media/json/module.f.js';
4
8
  import { option, record, string } from '../../types/rtti/module.f.js';
5
9
  import { validate as rttiValidate } from '../../types/rtti/validate/module.f.js';
6
10
  import { error, ok } from '../../types/result/module.f.js';
@@ -10,7 +14,6 @@ export const packageJsonSchema = {
10
14
  scripts: option(record(string)),
11
15
  };
12
16
  export const validatePackageJson = rttiValidate(packageJsonSchema);
13
- const parseJsonText = (text) => jsonParse(jsonTokenize(stringToList(text)));
14
17
  export const validatePackageJsonText = (text) => {
15
18
  const [t, v] = parseJsonText(text);
16
19
  if (t === 'error') {
@@ -2,13 +2,22 @@ import { assert, assertEq, todo } from '../asserts/module.f.js';
2
2
  import { shouldLoad } from './module.f.js';
3
3
  export const proof = {
4
4
  shouldLoad: () => {
5
+ // Every FunctionalScript extension is loaded whatever the file name.
5
6
  assert(shouldLoad('foo.f.ts'));
7
+ assert(shouldLoad('foo.f.mts'));
6
8
  assert(shouldLoad('bar.f.js'));
9
+ assert(shouldLoad('module.f.mjs'));
10
+ assert(shouldLoad('proof.f.mts'));
11
+ assert(shouldLoad('proof.f.mjs'));
12
+ // Every impure JS/TS extension is loaded only under the `proof` name.
7
13
  assert(shouldLoad('proof.ts'));
8
- assert(shouldLoad('proof.js'));
9
14
  assert(shouldLoad('proof.mts'));
15
+ assert(shouldLoad('proof.js'));
10
16
  assert(shouldLoad('proof.mjs'));
11
17
  assert(!shouldLoad('module.ts'));
18
+ assert(!shouldLoad('module.mts'));
19
+ assert(!shouldLoad('module.js'));
20
+ assert(!shouldLoad('module.mjs'));
12
21
  assert(!shouldLoad('readme.md'));
13
22
  },
14
23
  shouldPass: () => ({
@@ -4,7 +4,7 @@
4
4
  * @module
5
5
  */
6
6
  import { error, ok } from '../../types/result/module.f.js';
7
- import { fold, first, drop, toArray, length, concat } from '../../types/list/module.f.js';
7
+ import { fold, next, toArray, length, concat } from '../../types/list/module.f.js';
8
8
  import { setReplace, at } from '../../types/ordered_map/module.f.js';
9
9
  import { fromMap } from '../../types/object/module.f.js';
10
10
  import { assertEq } from '../../asserts/module.f.js';
@@ -164,9 +164,19 @@ const startArray = state => {
164
164
  const newStack = state.top === null ? null : { first: state.top, tail: state.stack };
165
165
  return { ...state, valueState: '[', top: ['array', null], stack: newStack };
166
166
  };
167
+ // Pops the enclosing container off `stack`. `next` is forced here rather than
168
+ // left as a `drop(1)` thunk: the stack is written only by startArray/startObject,
169
+ // always as a literal cons, and a lazy pop leaves one unforced thunk per closed
170
+ // container — a chain that overflows the call stack when it is finally forced.
171
+ const popStack = state => {
172
+ const ne = next(state.stack);
173
+ return ne === null
174
+ ? { ...state, valueState: '', top: null, stack: null }
175
+ : { ...state, valueState: '', top: ne.first, stack: ne.tail };
176
+ };
167
177
  const endArray = state => {
168
178
  const top = state.top;
169
- const newState = { ...state, valueState: '', top: first(null)(state.stack), stack: drop(1)(state.stack) };
179
+ const newState = popStack(state);
170
180
  if (top !== null && top[0] === 'array') {
171
181
  const array = ['array', toArray(top[1])];
172
182
  return pushValue(newState)(array);
@@ -179,7 +189,7 @@ const startObject = state => {
179
189
  };
180
190
  const endObject = state => {
181
191
  const obj = state?.top !== null && state?.top[0] === 'object' ? fromMap(state.top[1]) : null;
182
- const newState = { ...state, valueState: '', top: first(null)(state.stack), stack: drop(1)(state.stack) };
192
+ const newState = popStack(state);
183
193
  return pushValue(newState)(obj);
184
194
  };
185
195
  const tokenToValue = token => {
@@ -10,4 +10,5 @@ export declare const proof: {
10
10
  validWithArgs: (() => void)[];
11
11
  invalidWithArgs: (() => void)[];
12
12
  comments: (() => void)[];
13
+ containerStackCost: (() => void)[];
13
14
  };