@xandreed/core 0.12.0 → 0.14.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 (155) hide show
  1. package/README.md +1 -1
  2. package/dist/conformance.functions.d.ts +34 -0
  3. package/dist/conformance.functions.js +25 -0
  4. package/dist/domain/bash.entity.d.ts +82 -0
  5. package/dist/domain/bash.entity.functions.d.ts +17 -0
  6. package/dist/domain/bash.entity.functions.js +26 -0
  7. package/dist/domain/bash.entity.functions.test.d.ts +1 -0
  8. package/dist/domain/bash.entity.js +30 -0
  9. package/dist/domain/file-system.entity.d.ts +91 -0
  10. package/dist/domain/file-system.entity.functions.d.ts +18 -0
  11. package/dist/domain/file-system.entity.functions.js +30 -0
  12. package/dist/domain/file-system.entity.js +37 -0
  13. package/dist/domain/loop-event.entity.d.ts +9 -0
  14. package/dist/domain/media.entity.d.ts +79 -0
  15. package/dist/domain/media.entity.functions.d.ts +76 -0
  16. package/dist/domain/media.entity.functions.js +110 -0
  17. package/dist/domain/media.entity.js +38 -0
  18. package/dist/domain/media.entity.test.d.ts +1 -0
  19. package/dist/domain/message.entity.d.ts +157 -5
  20. package/dist/domain/message.entity.js +8 -4
  21. package/dist/domain/model-catalog.entity.d.ts +2 -0
  22. package/dist/domain/model-catalog.entity.js +2 -1
  23. package/dist/domain/token-usage.entity.d.ts +45 -1
  24. package/dist/domain/token-usage.entity.functions.d.ts +30 -1
  25. package/dist/domain/token-usage.entity.functions.js +24 -4
  26. package/dist/domain/token-usage.entity.js +19 -2
  27. package/dist/domain/token-usage.entity.test.d.ts +1 -0
  28. package/dist/harness/capability.entity.functions.js +2 -1
  29. package/dist/harness/plugin.adapter.d.ts +3 -0
  30. package/dist/harness/plugin.adapter.js +7 -1
  31. package/dist/harness/skill-files.entity.d.ts +101 -0
  32. package/dist/harness/skill-files.entity.functions.d.ts +58 -0
  33. package/dist/harness/skill-files.entity.functions.js +150 -0
  34. package/dist/harness/skill-files.entity.functions.test.d.ts +1 -0
  35. package/dist/harness/skill-files.entity.js +25 -0
  36. package/dist/index.d.ts +48 -10
  37. package/dist/index.js +67 -14
  38. package/dist/loop/cacheKey.d.ts +1 -1
  39. package/dist/loop/mapping.d.ts +13 -7
  40. package/dist/loop/mapping.js +30 -6
  41. package/dist/loop/stepContext.d.ts +8 -0
  42. package/dist/loop/stepContext.js +1 -1
  43. package/dist/loop/traceContent.d.ts +29 -0
  44. package/dist/loop/traceContent.js +4 -0
  45. package/dist/loop/traceContent.test.d.ts +1 -0
  46. package/dist/memory/memory-log.entity.d.ts +152 -14
  47. package/dist/memory/memory-log.entity.functions.d.ts +35 -5
  48. package/dist/memory/memory-log.entity.functions.js +59 -20
  49. package/dist/memory/memory-log.entity.js +19 -6
  50. package/dist/memory/memory-session.js +10 -6
  51. package/dist/memory/memory.conformance.d.ts +8 -1
  52. package/dist/memory/memory.conformance.js +32 -0
  53. package/dist/models/auth-store.conformance.d.ts +13 -0
  54. package/dist/models/auth-store.conformance.js +73 -0
  55. package/dist/models/auth-store.conformance.test.d.ts +1 -0
  56. package/dist/models/model-catalog.conformance.d.ts +15 -0
  57. package/dist/models/model-catalog.conformance.js +27 -0
  58. package/dist/models/model-catalog.conformance.test.d.ts +1 -0
  59. package/dist/models/settings-store.conformance.d.ts +26 -0
  60. package/dist/models/settings-store.conformance.js +80 -0
  61. package/dist/models/settings-store.conformance.test.d.ts +1 -0
  62. package/dist/ports/activity.port.d.ts +35 -0
  63. package/dist/ports/activity.port.js +7 -0
  64. package/dist/ports/auth-store.port.d.ts +7 -5
  65. package/dist/ports/bash.port.d.ts +22 -0
  66. package/dist/ports/bash.port.js +4 -0
  67. package/dist/ports/capability.port.d.ts +11 -1
  68. package/dist/ports/file-system.port.d.ts +44 -15
  69. package/dist/ports/file-system.port.js +1 -7
  70. package/dist/ports/harness.port.d.ts +1 -99
  71. package/dist/ports/harness.port.js +0 -24
  72. package/dist/ports/memory.port.d.ts +13 -1
  73. package/dist/ports/model-catalog.port.d.ts +4 -1
  74. package/dist/ports/run-context.port.d.ts +6 -1
  75. package/dist/ports/session-log.port.d.ts +4 -1
  76. package/dist/ports/sessions.port.d.ts +35 -6
  77. package/dist/ports/settings-store.port.d.ts +11 -7
  78. package/dist/ports/step-loop.port.d.ts +30 -2
  79. package/dist/ports/tasks.port.d.ts +5 -1
  80. package/dist/ports/turn-admission.port.d.ts +8 -2
  81. package/dist/ports/turn-events.port.d.ts +8 -1
  82. package/dist/ports/turn-scope.port.d.ts +19 -1
  83. package/dist/ports/turn.port.d.ts +70 -5
  84. package/dist/ports/utility-llm.port.d.ts +2 -0
  85. package/dist/session/activity.entity.d.ts +42 -0
  86. package/dist/session/activity.entity.functions.d.ts +21 -0
  87. package/dist/session/activity.entity.functions.js +23 -0
  88. package/dist/session/activity.entity.functions.test.d.ts +1 -0
  89. package/dist/session/activity.entity.js +15 -0
  90. package/dist/session/chassis.js +2 -1
  91. package/dist/session/session-event.entity.d.ts +68 -6
  92. package/dist/session/session-event.entity.functions.d.ts +34 -2
  93. package/dist/session/session-event.entity.functions.js +54 -16
  94. package/dist/session/session-event.entity.js +19 -2
  95. package/dist/session/session-log.conformance.d.ts +4 -3
  96. package/dist/session/session-log.conformance.js +16 -14
  97. package/dist/session/session-log.recovery.conformance.d.ts +40 -0
  98. package/dist/session/session-log.recovery.conformance.js +143 -0
  99. package/dist/session/session-log.recovery.conformance.test.d.ts +1 -0
  100. package/dist/session/sessions.conformance.d.ts +79 -0
  101. package/dist/session/sessions.conformance.js +355 -0
  102. package/dist/session/sessions.entity.d.ts +36 -3
  103. package/dist/session/sessions.entity.functions.d.ts +42 -0
  104. package/dist/session/sessions.entity.functions.js +26 -0
  105. package/dist/session/sessions.entity.js +17 -4
  106. package/dist/session/sessions.entity.test.d.ts +1 -0
  107. package/dist/session/tasks.conformance.d.ts +70 -0
  108. package/dist/session/tasks.conformance.js +213 -0
  109. package/dist/session/tasks.conformance.test.d.ts +1 -0
  110. package/dist/turn/model-request.entity.d.ts +4 -0
  111. package/dist/turn/model-request.entity.js +7 -2
  112. package/dist/turn/step-loop.conformance.d.ts +4 -1
  113. package/dist/turn/step-loop.conformance.js +73 -7
  114. package/dist/turn/tool-registry.conformance.d.ts +38 -0
  115. package/dist/turn/tool-registry.conformance.js +206 -0
  116. package/dist/turn/turn-bus.js +4 -3
  117. package/dist/turn/turn-event.entity.d.ts +134 -34
  118. package/dist/turn/turn-event.entity.functions.d.ts +11 -0
  119. package/dist/turn/turn-event.entity.functions.js +9 -1
  120. package/dist/turn/turn-event.entity.js +76 -3
  121. package/dist/turn/turn-event.entity.test.d.ts +1 -0
  122. package/dist/turn/turn-lifecycle.d.ts +46 -8
  123. package/dist/turn/turn-lifecycle.js +33 -3
  124. package/dist/turn/turn-lifecycle.test.d.ts +1 -0
  125. package/dist/turn/turn-run.d.ts +15 -3
  126. package/dist/turn/turn-run.js +37 -9
  127. package/dist/turn/turn.adapter.d.ts +6 -5
  128. package/dist/turn/turn.adapter.js +15 -3
  129. package/dist/turn/turn.conformance.d.ts +11 -2
  130. package/dist/turn/turn.conformance.js +63 -2
  131. package/dist/turn/user-message.entity.d.ts +14 -4
  132. package/dist/turn/user-message.entity.functions.d.ts +21 -0
  133. package/dist/turn/user-message.entity.functions.js +24 -0
  134. package/dist/turn/user-message.entity.js +6 -8
  135. package/dist/turn/user-message.entity.test.d.ts +1 -0
  136. package/dist/util/glob.d.ts +30 -0
  137. package/dist/util/glob.js +91 -0
  138. package/dist/util/glob.test.d.ts +1 -0
  139. package/dist/workspace/bash.conformance.d.ts +37 -0
  140. package/dist/workspace/bash.conformance.js +120 -0
  141. package/dist/workspace/bash.conformance.test.d.ts +1 -0
  142. package/dist/workspace/file-system.conformance.d.ts +38 -0
  143. package/dist/workspace/file-system.conformance.js +254 -0
  144. package/dist/workspace/file-system.conformance.test.d.ts +1 -0
  145. package/dist/workspace/file-system.memory.adapter.d.ts +23 -0
  146. package/dist/workspace/file-system.memory.adapter.js +132 -0
  147. package/package.json +6 -6
  148. package/dist/harness/session.entity.d.ts +0 -33
  149. package/dist/harness/session.entity.js +0 -25
  150. package/dist/harness/skill-files.d.ts +0 -17
  151. package/dist/harness/skill-files.js +0 -25
  152. package/dist/ports/conversation-store.port.d.ts +0 -127
  153. package/dist/ports/conversation-store.port.js +0 -36
  154. package/dist/ports/shell.port.d.ts +0 -32
  155. package/dist/ports/shell.port.js +0 -16
@@ -1,14 +1,37 @@
1
- import { Context, Deferred, Effect, Fiber, Layer, Option, Ref } from "effect";
1
+ import { LanguageModel } from "effect/ai";
2
+ import { Context, Deferred, Effect, Fiber, Layer, Option, Ref, Stream } from "effect";
2
3
  import { ConformanceFailure } from "../conformance.entity.js";
4
+ import { pixelPng, sameUsage } from "../conformance.functions.js";
5
+ import { mediaFromBase64 } from "../domain/media.entity.functions.js";
6
+ import { addUsage, tokenUsage, zeroUsage } from "../domain/token-usage.entity.functions.js";
3
7
  import { HarnessError } from "../harness/plugin.entity.js";
4
8
  import { IntentMatcher } from "../ports/capability.port.js";
5
9
  import { RunContext } from "../ports/run-context.port.js";
6
10
  import { Sessions } from "../ports/sessions.port.js";
11
+ import { turnUsageOf } from "../session/session-event.entity.functions.js";
7
12
  import { UserMessage } from "./user-message.entity.js";
8
13
  const fail = (check) => (message) => Effect.fail(new ConformanceFailure({ check, message })), expect = (check, holds, message) => holds ? Effect.void : fail(check)(message), stored = (session) => session.sessions.read(session.address).pipe(Effect.mapError((error) => new HarnessError({ code: "conformance.session", message: error._tag }))), ofRun = (all, runId) => {
9
14
  const turn = Option.flatMap(Option.fromNullishOr(all.find((event) => event.kind === "turn.started" && event.data.runId === runId)), (event) => event.turn);
10
15
  return Option.match(turn, { onNone: () => [], onSome: (number) => all.filter((event) => Option.contains(event.turn, number)) });
11
- }, names = (session, runId) => stored(session).pipe(Effect.map((all) => ofRun(all, runId).map((event) => event.kind)));
16
+ }, names = (session, runId) => stored(session).pipe(Effect.map((all) => ofRun(all, runId).map((event) => event.kind))), billedCall = (index) => ({
17
+ inputTokens: { total: 100 + index, uncached: 50 + index, cacheRead: 40, cacheWrite: 10 },
18
+ outputTokens: { total: 20, text: 15, reasoning: 5 }
19
+ }), billingModel = Effect.gen(function* () {
20
+ const calls = yield* Ref.make(0);
21
+ return { model: yield* LanguageModel.make({
22
+ generateText: () => Ref.getAndUpdate(calls, (count) => count + 1).pipe(Effect.map((index) => [
23
+ { type: "text", text: `answer ${index}` },
24
+ { type: "finish", reason: "stop", usage: billedCall(index) }
25
+ ])),
26
+ streamText: () => Stream.die("the conformance model does not stream")
27
+ }), calls: Ref.get(calls) };
28
+ }), billedTotal = (count) => Array.from({ length: count }, (_, index) => billedCall(index)).reduce((total, call) => addUsage(total, tokenUsage({
29
+ inputTokens: call.inputTokens.total,
30
+ outputTokens: call.outputTokens.total,
31
+ cacheReadTokens: call.inputTokens.cacheRead,
32
+ cacheWriteTokens: call.inputTokens.cacheWrite,
33
+ reasoningTokens: call.outputTokens.reasoning
34
+ })), zeroUsage);
12
35
  export const turnConformance = (runner, services = Context.empty()) => {
13
36
  const check = (name, id, body) => ({
14
37
  name,
@@ -60,6 +83,44 @@ export const turnConformance = (runner, services = Context.empty()) => {
60
83
  const kinds = yield* names(session, "run-1");
61
84
  yield* expect("tasks-joined", kinds.includes("conformance.task") && kinds.indexOf("conformance.task") < kinds.indexOf("turn.reply"), `the task's event came ${kinds.includes("conformance.task") ? "after the reply" : "never"}`);
62
85
  })),
86
+ check("a model-driven turn returns its usage: what its stored step.usage records sum to", "usage", (turns, session) => Effect.gen(function* () {
87
+ const billing = yield* billingModel, runs = yield* Ref.make(zeroUsage), during = yield* Ref.make(zeroUsage), result = yield* turns.turn({ ...input(session, "run-1"), services: Context.make(LanguageModel.LanguageModel, billing.model) }, (turn) => Effect.gen(function* () {
88
+ const policy = {
89
+ limits: { streaming: !1, maxSteps: 2, requireCompletion: !1 },
90
+ completion: () => Effect.succeed({ complete: !0, awaiting: [], facts: {} })
91
+ }, first = yield* turn.run(policy), second = yield* turn.run(policy);
92
+ yield* Ref.set(runs, addUsage(first.usage, second.usage));
93
+ yield* Ref.set(during, yield* turn.usage);
94
+ return { outcome: "completed", reply: Option.some(second.text) };
95
+ }));
96
+ yield* expect("usage", sameUsage(yield* Ref.get(during), yield* Ref.get(runs)), `Turn.usage read ${JSON.stringify(yield* Ref.get(during))}, the turn's runs ${JSON.stringify(yield* Ref.get(runs))}`);
97
+ const all = yield* stored(session), number = Option.flatMap(Option.fromNullishOr(all.find((event) => event.kind === "turn.started" && event.data.runId === "run-1")), (event) => event.turn);
98
+ yield* expect("usage", Option.isSome(number), "the turn's turn.started was not stored");
99
+ const total = yield* turnUsageOf(all, Option.getOrElse(number, () => -1)).pipe(Effect.mapError((error) => new ConformanceFailure({ check: "usage", message: error.message })));
100
+ yield* expect("usage", sameUsage(result.usage, total), `the turn returned ${JSON.stringify(result.usage)}, its stored step.usage records sum to ${JSON.stringify(total)}`);
101
+ yield* expect("usage", sameUsage(result.usage, yield* Ref.get(runs)), `the turn returned ${JSON.stringify(result.usage)}, its runs ${JSON.stringify(yield* Ref.get(runs))}`);
102
+ const calls = yield* billing.calls;
103
+ yield* expect("usage", calls >= 2 && sameUsage(result.usage, billedTotal(calls)), `the turn's usage lost the provider's counts: ${JSON.stringify(result.usage)} for ${calls} model calls, which reported ${JSON.stringify(billedTotal(calls))}`);
104
+ })),
105
+ check("the user's attachments are stored with the turn and reach the first request as image parts", "attachments", (turns, session) => Effect.gen(function* () {
106
+ const image = yield* mediaFromBase64(pixelPng, { name: "pixel.png" }).pipe(Effect.mapError((error) => new ConformanceFailure({ check: "attachments", message: error.message }))), requests = yield* Ref.make([]), model = yield* LanguageModel.make({
107
+ generateText: (options) => Ref.update(requests, (all) => [...all, options.prompt]).pipe(Effect.as([
108
+ { type: "text", text: "a pixel" },
109
+ { type: "finish", reason: "stop", usage: billedCall(0) }
110
+ ])),
111
+ streamText: () => Stream.die("the conformance model does not stream")
112
+ }), userMessage = new UserMessage({ text: "what is this?", attachments: [image] });
113
+ yield* turns.turn({ turn: { session: session.address, userMessage, runId: "run-1" }, services: Context.make(LanguageModel.LanguageModel, model) }, (turn) => turn.run({
114
+ limits: { streaming: !1, maxSteps: 1, requireCompletion: !1 },
115
+ completion: () => Effect.succeed({ complete: !0, awaiting: [], facts: {} })
116
+ }).pipe(Effect.map((result) => ({ outcome: "completed", reply: Option.some(result.text) }))));
117
+ const first = (yield* Ref.get(requests))[0];
118
+ yield* expect("attachments", first !== void 0, "the turn made no request");
119
+ const files = (first?.content ?? []).flatMap((message) => message.role === "user" ? message.content.flatMap((part) => part.type === "file" ? [part] : []) : []);
120
+ yield* expect("attachments", files.some((part) => part.mediaType === image.mediaType && part.data === image.data), `the first request carried ${files.length} file parts, none of them the attached ${image.mediaType}`);
121
+ const started = (yield* stored(session)).find((event) => event.kind === "turn.started" && event.data.runId === "run-1");
122
+ yield* expect("attachments", JSON.stringify(started?.data.userMessage ?? null).includes(image.fingerprint), "the stored turn.started does not hold the attachment");
123
+ })),
63
124
  check("the host layer is built before the message: it sees only earlier turns", "layer-before-message", (turns, session) => Effect.gen(function* () {
64
125
  const seen = yield* Ref.make([]), layer = Layer.effectDiscard(Effect.gen(function* () {
65
126
  const run = yield* RunContext, own = (yield* run.memory.entries).filter((entry) => entry.runId === run.runId).length, turn = yield* run.memory.turn;
@@ -1,14 +1,24 @@
1
1
  import { Schema } from "effect";
2
2
  declare const UserMessage_base: Schema.Class<UserMessage, Schema.Struct<{
3
- readonly text: Schema.NonEmptyString;
3
+ readonly text: Schema.String;
4
+ readonly attachments: Schema.optionalKey<Schema.$Array<Schema.Struct<{
5
+ readonly type: Schema.Literal<"media">;
6
+ readonly mediaType: Schema.Literals<readonly ["image/png", "image/jpeg", "image/gif", "image/webp"]>;
7
+ readonly data: Schema.String;
8
+ readonly fingerprint: Schema.brand<Schema.String, "MediaFingerprint">;
9
+ readonly bytes: Schema.Int;
10
+ readonly name: Schema.optionalKey<Schema.String>;
11
+ readonly alt: Schema.optionalKey<Schema.String>;
12
+ }>>>;
4
13
  }>, {}>;
5
14
  /**
6
15
  * What the user said to start a turn. A prompt is text sent to a model; the
7
16
  * user's message is a value of its own, and every field or parameter holding
8
- * it is named `userMessage`. Attachments may join `text` later.
17
+ * it is named `userMessage`. It has text, images (`attachments`, at most
18
+ * `MEDIA_LIMITS.attachments`), or both: the text may be empty only when
19
+ * there is an image. `Sessions.begin` checks each image's bytes and stores
20
+ * the message as `normalizeUserMessage` gives it.
9
21
  */
10
22
  export declare class UserMessage extends UserMessage_base {
11
23
  }
12
- /** A `UserMessage` stored as its plain text, for records that keep a string on the wire. */
13
- export declare const UserMessageFromString: Schema.decodeTo<typeof UserMessage, Schema.String, never, never>;
14
24
  export {};
@@ -0,0 +1,21 @@
1
+ import { Effect } from "effect";
2
+ import { AttachmentRejected } from "../domain/media.entity.js";
3
+ import type { Media } from "../domain/media.entity.js";
4
+ import { UserMessage } from "./user-message.entity.js";
5
+ /** The message's images, in order (none for a text-only message). */
6
+ export declare const attachmentsOf: (message: UserMessage) => ReadonlyArray<Media>;
7
+ /**
8
+ * The message as one text, for transcripts, digests and ledgers: its text,
9
+ * then one `mediaLabel` line per image. A text-only message is its text.
10
+ */
11
+ export declare const userMessageText: (message: UserMessage) => string;
12
+ /** The same message: equal text and the same images (by fingerprint, type, size, name and alt), in order. */
13
+ export declare const sameUserMessage: (left: UserMessage, right: UserMessage) => boolean;
14
+ /**
15
+ * The message with each image checked again from its own bytes
16
+ * (`normalizeMedia`: type, size and fingerprint as the bytes give them), and
17
+ * the images together within `MEDIA_LIMITS.requestChars`. A text-only
18
+ * message is returned as it is. `Sessions.begin` stores what this returns,
19
+ * and compares a retried key against it.
20
+ */
21
+ export declare const normalizeUserMessage: (message: UserMessage) => Effect.Effect<UserMessage, AttachmentRejected>;
@@ -0,0 +1,24 @@
1
+ import { Effect } from "effect";
2
+ import { AttachmentRejected, MEDIA_LIMITS } from "../domain/media.entity.js";
3
+ import { mediaLabel, normalizeMedia } from "../domain/media.entity.functions.js";
4
+ import { UserMessage } from "./user-message.entity.js";
5
+ export const attachmentsOf = (message) => message.attachments ?? [], userMessageText = (message) => [message.text, ...attachmentsOf(message).map(mediaLabel)].filter((line) => line.length > 0).join(`
6
+ `);
7
+ const sameMedia = (left, right) => left.fingerprint === right.fingerprint && left.mediaType === right.mediaType && left.bytes === right.bytes && left.name === right.name && left.alt === right.alt;
8
+ export const sameUserMessage = (left, right) => {
9
+ const [a, b] = [attachmentsOf(left), attachmentsOf(right)];
10
+ return left.text === right.text && a.length === b.length && a.every((media, index) => {
11
+ const other = b[index];
12
+ return other !== void 0 && sameMedia(media, other);
13
+ });
14
+ }, normalizeUserMessage = (message) => {
15
+ const attachments = attachmentsOf(message);
16
+ if (attachments.length === 0)
17
+ return Effect.succeed(message);
18
+ if (attachments.length > MEDIA_LIMITS.attachments)
19
+ return Effect.fail(new AttachmentRejected({ reason: "TooMany", message: `${attachments.length} attachments, more than ${MEDIA_LIMITS.attachments}` }));
20
+ return Effect.forEach(attachments, (media, index) => normalizeMedia(media).pipe(Effect.mapError((error) => new AttachmentRejected({ reason: error.reason, index, message: `attachment ${index}: ${error.message}` })))).pipe(Effect.flatMap((normalized) => {
21
+ const chars = normalized.reduce((sum, media) => sum + media.data.length, 0);
22
+ return chars > MEDIA_LIMITS.requestChars ? Effect.fail(new AttachmentRejected({ reason: "TooLargeTogether", message: `the attachments take ${chars} base64 characters together, more than ${MEDIA_LIMITS.requestChars}` })) : Effect.succeed(new UserMessage({ text: message.text, attachments: normalized }));
23
+ }));
24
+ };
@@ -1,10 +1,8 @@
1
- import { Schema, SchemaTransformation } from "effect";
1
+ import { Schema } from "effect";
2
+ import { MEDIA_LIMITS, Media } from "../domain/media.entity.js";
2
3
 
3
- export class UserMessage extends Schema.Class("UserMessage")({
4
- text: Schema.NonEmptyString
5
- }) {
4
+ export class UserMessage extends Schema.Class("UserMessage")(Schema.Struct({
5
+ text: Schema.String,
6
+ attachments: Schema.optionalKey(Schema.Array(Media).check(Schema.isMinLength(1), Schema.isMaxLength(MEDIA_LIMITS.attachments)))
7
+ }).check(Schema.makeFilter((message) => message.text.length > 0 || message.attachments !== void 0 ? !0 : "a user message needs text or an attachment"))) {
6
8
  }
7
- export const UserMessageFromString = Schema.String.pipe(Schema.decodeTo(UserMessage, SchemaTransformation.transform({
8
- decode: (text) => ({ text }),
9
- encode: (userMessage) => userMessage.text
10
- })));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import { Result } from "effect";
2
+ /**
3
+ * Glob patterns over POSIX paths relative to a root, without a file system:
4
+ * `*` and `?` match within one path segment, `**` (a whole segment) matches
5
+ * any number of segments, `[abc]`, `[a-z]` and `[!a]` / `[^a]` match one
6
+ * character, `{a,b}` (nestable) is either alternative, and `\` makes the
7
+ * next character literal. A `[` with no closing `]` is literal, as is a
8
+ * `{...}` with no top-level comma. Leading `./` and empty segments are
9
+ * ignored. A segment matches a name starting with a dot only when `dot` is
10
+ * set or the pattern segment itself starts with a literal dot; `**` never
11
+ * crosses such names without `dot`. A pattern whose braces stand for more
12
+ * than 1024 patterns is refused, never cut short.
13
+ */
14
+ export interface GlobMatcher {
15
+ /** Whether this relative path matches the pattern. */
16
+ readonly matches: (path: string) => boolean;
17
+ /** Whether the directory at this relative path can hold a match, so a walk should enter it. */
18
+ readonly descends: (dir: string) => boolean;
19
+ }
20
+ export interface GlobMatchOptions {
21
+ readonly dot?: boolean;
22
+ }
23
+ /**
24
+ * Compile a pattern once into a path matcher and the walk's descent test.
25
+ * Fails, with the reason, when the braces stand for more than 1024 patterns
26
+ * or a part does not compile.
27
+ */
28
+ export declare const compileGlob: (pattern: string, options?: GlobMatchOptions) => Result.Result<GlobMatcher, string>;
29
+ /** Whether a relative POSIX path matches the pattern (see `compileGlob`, and when it fails). */
30
+ export declare const globMatcher: (pattern: string, options?: GlobMatchOptions) => Result.Result<(path: string) => boolean, string>;
@@ -0,0 +1,91 @@
1
+ import { Option, Result } from "effect";
2
+ const maxAlternatives = 1024, syntax = new Set(["^", "$", "\\", ".", "*", "+", "?", "(", ")", "[", "]", "{", "}", "|", "/"]), literal = (char) => syntax.has(char) ? `\\${char}` : char, classLiteral = (char) => char === "\\" || char === "]" || char === "[" || char === "^" ? `\\${char}` : char, escapedFlags = (chars) => chars.reduce((state, char) => ({ flags: [...state.flags, state.pending], pending: !state.pending && char === "\\" }), { flags: [], pending: !1 }).flags, firstBrace = (pattern) => {
3
+ const chars = Array.from(pattern), escaped = escapedFlags(chars), special = (index, char) => chars[index] === char && escaped[index] !== !0, scanFrom = (start) => {
4
+ const open = chars.findIndex((_, index) => index >= start && special(index, "{"));
5
+ if (open < 0)
6
+ return Option.none();
7
+ const scan = chars.reduce((state, _, index) => {
8
+ if (index <= open || Option.isSome(state.close))
9
+ return state;
10
+ if (special(index, "{"))
11
+ return { ...state, depth: state.depth + 1 };
12
+ if (special(index, ",") && state.depth === 0)
13
+ return { ...state, commas: [...state.commas, index] };
14
+ if (!special(index, "}"))
15
+ return state;
16
+ return state.depth > 0 ? { ...state, depth: state.depth - 1 } : { ...state, close: Option.some(index) };
17
+ }, { depth: 0, commas: [], close: Option.none() });
18
+ return Option.match(Option.filter(scan.close, () => scan.commas.length > 0), {
19
+ onNone: () => scanFrom(open + 1),
20
+ onSome: (close) => Option.some({ open, close, commas: scan.commas })
21
+ });
22
+ };
23
+ return scanFrom(0);
24
+ }, expandBraces = (pattern) => Option.match(firstBrace(pattern), {
25
+ onNone: () => [pattern],
26
+ onSome: ({ open, close, commas }) => {
27
+ const chars = Array.from(pattern), bounds = [open, ...commas, close], before = chars.slice(0, open).join(""), after = chars.slice(close + 1).join("");
28
+ return bounds.slice(1).map((end, index) => chars.slice((bounds[index] ?? open) + 1, end).join("")).reduce((all, alternative) => all.length > maxAlternatives ? all : [...all, ...expandBraces(`${before}${alternative}${after}`)].slice(0, maxAlternatives + 1), []);
29
+ }
30
+ }), codePoint = (member) => member?.char.codePointAt(0) ?? 0, classAt = (chars, open) => {
31
+ const negated = chars[open + 1] === "!" || chars[open + 1] === "^", first = open + 1 + (negated ? 1 : 0), close = chars.findIndex((char, index) => index > first && char === "]" && chars[index - 1] !== "\\");
32
+ if (close < 0)
33
+ return Option.none();
34
+ const members = chars.slice(first, close).reduce((state, char) => !state.escaping && char === "\\" ? { ...state, escaping: !0 } : { members: [...state.members, { char, escaped: state.escaping }], escaping: !1 }, { members: [], escaping: !1 }).members, isRange = (member, index) => !member.escaped && member.char === "-" && index > 0 && index < members.length - 1;
35
+ if (members.some((member, index) => isRange(member, index) && codePoint(members[index - 1]) > codePoint(members[index + 1])))
36
+ return Option.none();
37
+ const source = members.map((member, index) => isRange(member, index) ? "-" : member.char === "-" ? "\\-" : classLiteral(member.char)).join("");
38
+ return Option.some({ source: `[${negated ? "^" : ""}${source}]`, close });
39
+ }, segmentPattern = (segment, dot) => {
40
+ const chars = Array.from(segment), body = chars.reduce((state, char, index) => {
41
+ if (index < state.next)
42
+ return state;
43
+ if (char === "\\")
44
+ return { source: state.source + literal(chars[index + 1] ?? "\\"), next: index + 2 };
45
+ if (char === "*")
46
+ return { source: state.source + "[^/]*", next: index + 1 };
47
+ if (char === "?")
48
+ return { source: state.source + "[^/]", next: index + 1 };
49
+ if (char === "[")
50
+ return Option.match(classAt(chars, index), {
51
+ onNone: () => ({ source: state.source + literal(char), next: index + 1 }),
52
+ onSome: (found) => ({ source: state.source + found.source, next: found.close + 1 })
53
+ });
54
+ return { source: state.source + literal(char), next: index + 1 };
55
+ }, { source: "", next: 0 }), explicitDot = chars[0] === "." || chars[0] === "\\" && chars[1] === ".";
56
+ return new RegExp(`^${dot || explicitDot ? "" : "(?!\\.)"}${body.source}$`, "u");
57
+ }, segmentsOf = (path) => path.split("/").filter((segment) => segment.length > 0 && segment !== "."), compileOne = (pattern, dot) => segmentsOf(pattern).map((segment) => segment === "**" ? { _tag: "Globstar" } : { _tag: "Name", pattern: segmentPattern(segment, dot) }).filter((segment, index, all) => !(segment._tag === "Globstar" && all[index - 1]?._tag === "Globstar")), matchFrom = (pattern, at, names, from, dot) => {
58
+ const segment = pattern[at];
59
+ if (segment === void 0)
60
+ return from === names.length;
61
+ const name = names[from];
62
+ if (segment._tag === "Globstar")
63
+ return matchFrom(pattern, at + 1, names, from, dot) || name !== void 0 && (dot || !name.startsWith(".")) && matchFrom(pattern, at, names, from + 1, dot);
64
+ return name !== void 0 && segment.pattern.test(name) && matchFrom(pattern, at + 1, names, from + 1, dot);
65
+ }, prefixFrom = (pattern, at, names, from, dot) => {
66
+ const segment = pattern[at];
67
+ if (segment === void 0)
68
+ return !1;
69
+ const name = names[from];
70
+ if (name === void 0)
71
+ return !0;
72
+ if (segment._tag === "Globstar")
73
+ return prefixFrom(pattern, at + 1, names, from, dot) || (dot || !name.startsWith(".")) && prefixFrom(pattern, at, names, from + 1, dot);
74
+ return segment.pattern.test(name) && prefixFrom(pattern, at + 1, names, from + 1, dot);
75
+ };
76
+ export const compileGlob = (pattern, options) => {
77
+ const dot = options?.dot ?? !1, expanded = expandBraces(pattern);
78
+ if (expanded.length > maxAlternatives)
79
+ return Result.fail(`its braces stand for more than ${maxAlternatives} patterns`);
80
+ const compiled = Result.try({ try: () => expanded.map((one) => compileOne(one, dot)), catch: (cause) => `it does not compile: ${String(cause)}` });
81
+ return Result.map(compiled, (all) => ({
82
+ matches: (path) => {
83
+ const names = segmentsOf(path);
84
+ return all.some((segments) => matchFrom(segments, 0, names, 0, dot));
85
+ },
86
+ descends: (dir) => {
87
+ const names = segmentsOf(dir);
88
+ return all.some((segments) => prefixFrom(segments, 0, names, 0, dot));
89
+ }
90
+ }));
91
+ }, globMatcher = (pattern, options) => Result.map(compileGlob(pattern, options), (matcher) => matcher.matches);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,37 @@
1
+ import { Effect } from "effect";
2
+ import type { Context, Scope } from "effect";
3
+ import type { ConformanceCheck } from "../conformance.entity.js";
4
+ import type { Bash } from "../ports/bash.port.js";
5
+ type BashShape = Context.Service.Shape<typeof Bash>;
6
+ /** One check's Bash adapter and an empty directory its commands run in. */
7
+ export interface BashFixture {
8
+ readonly bash: BashShape;
9
+ /** An absolute directory that exists and is empty. */
10
+ readonly root: string;
11
+ }
12
+ /**
13
+ * A Bash backend as the kit opens it, once per check, released with its
14
+ * scope. The adapter's parent environment must be the process's own plus
15
+ * `planted`, so the kit can check what a command inherits (a Node adapter
16
+ * reads `{ ...process.env, ...planted }` where it would read
17
+ * `process.env`). The shell itself is found the way the adapter always
18
+ * finds it, not through the command's environment.
19
+ */
20
+ export interface BashSubject {
21
+ readonly open: (planted: Readonly<Record<string, string>>) => Effect.Effect<BashFixture, unknown, Scope.Scope>;
22
+ }
23
+ /**
24
+ * The Bash contract as checks, each with a fresh fixture: a non-zero exit
25
+ * is a result; stdout and stderr stay apart and arrive as chunks while the
26
+ * command still runs, with exactly one `BashExit`, last; `stdin` (text or
27
+ * bytes) is delivered, its end is seen, and a command that ignores a large
28
+ * one still exits; `env` is set over the parent environment, which is filtered by
29
+ * `inheritEnv` (the default allowlist keeps `PATH` and `LC_*` but not a
30
+ * planted secret); a timeout kills the whole process group, a background
31
+ * child included, and reports `timeout`; `cwd` is honoured and a missing one
32
+ * fails with `CwdMissing`; a signal is reported; UTF-8 split across writes
33
+ * arrives whole; interrupting the stream kills the command and what it
34
+ * started. Each must finish within 30 seconds.
35
+ */
36
+ export declare const bashConformance: (subject: BashSubject) => ReadonlyArray<ConformanceCheck>;
37
+ export {};
@@ -0,0 +1,120 @@
1
+ import { Deferred, Effect, Fiber, Option, Ref, Stream } from "effect";
2
+ import { check, expect, fail } from "../conformance.functions.js";
3
+ const show = (value) => JSON.stringify(value), quoted = (word) => `'${word.replaceAll("'", "'\\''")}'`, textOf = (events, stream) => events.flatMap((event) => event._tag === "BashChunk" && event.stream === stream ? [event.text] : []).join(""), ended = (result) => show({
4
+ termination: result.termination,
5
+ exitCode: Option.getOrNull(result.exitCode),
6
+ signal: Option.getOrNull(result.signal),
7
+ stdout: result.stdout,
8
+ stderr: result.stderr
9
+ }), statusOf = (pidFiles) => pidFiles.map((file) => `pid=$(cat ${quoted(file)} 2>/dev/null) || { echo missing; exit 0; }; if kill -0 "$pid" 2>/dev/null; then case "$(ps -o stat= -p "$pid" 2>/dev/null)" in Z*) echo gone ;; *) echo alive ;; esac; else echo gone; fi`).join("; "), planted = { EFFERENT_KIT_SECRET: "planted-secret", LC_EFFERENT_KIT: "planted-locale", EFFERENT_KIT_OVER: "from the parent" };
10
+ export const bashConformance = (subject) => {
11
+ const bashCheck = (name, id, body, plant = {}) => check(name, id, () => Effect.timeoutOrElse(Effect.scoped(subject.open(plant).pipe(Effect.flatMap(body))), {
12
+ duration: "30 seconds",
13
+ orElse: () => fail(id, "the check did not finish in 30 seconds")
14
+ })), run = (fixture, command, options = {}) => fixture.bash.exec(command, { ...options, cwd: fixture.root }), allGone = (fixture, pidFiles, polls = 50) => run(fixture, statusOf(pidFiles)).pipe(Effect.flatMap((result) => {
15
+ const statuses = result.stdout.trim();
16
+ return statuses.split(`
17
+ `).every((status) => status === "gone") || polls === 0 ? Effect.succeed(statuses) : Effect.sleep("100 millis").pipe(Effect.andThen(allGone(fixture, pidFiles, polls - 1)));
18
+ })), runUntil = (fixture, command, marker) => Effect.gen(function* () {
19
+ const events = yield* Ref.make([]), seen = yield* Deferred.make(), fiber = yield* Effect.forkScoped(Stream.runForEach(fixture.bash.stream(command, { cwd: fixture.root }), (event) => Ref.updateAndGet(events, (all) => [...all, event]).pipe(Effect.flatMap((all) => textOf(all, "stdout").includes(marker) ? Deferred.succeed(seen, !0) : Effect.void))).pipe(Effect.ensuring(Deferred.succeed(seen, !1)))), arrived = Option.getOrElse(yield* Deferred.await(seen).pipe(Effect.timeoutOption("10 seconds")), () => !1);
20
+ return { events, fiber, arrived };
21
+ });
22
+ return [
23
+ bashCheck("a non-zero exit is a result with its code, not an error", "exit-code", (fixture) => Effect.gen(function* () {
24
+ const failed = yield* run(fixture, "echo bad >&2; exit 3");
25
+ yield* expect("exit-code", failed.termination === "exit" && Option.getOrNull(failed.exitCode) === 3 && Option.isNone(failed.signal) && failed.stderr === `bad
26
+ `, `a command exiting 3 ended ${ended(failed)}`);
27
+ const succeeded = yield* run(fixture, "true");
28
+ yield* expect("exit-code", succeeded.termination === "exit" && Option.getOrNull(succeeded.exitCode) === 0, `true ended ${ended(succeeded)}`);
29
+ })),
30
+ bashCheck("stdout and stderr stay apart, chunks carry their stream, and exactly one BashExit comes last", "streams", (fixture) => Effect.gen(function* () {
31
+ const result = yield* run(fixture, "printf out1; printf err1 >&2; printf out2; printf err2 >&2");
32
+ yield* expect("streams", result.stdout === "out1out2" && result.stderr === "err1err2", `the command ended ${ended(result)}`);
33
+ const events = yield* Stream.runCollect(fixture.bash.stream("printf 'one\\n'; printf 'two\\n' >&2; exit 4", { cwd: fixture.root })).pipe(Effect.map((all) => [...all])), exits = events.filter((event) => event._tag === "BashExit"), last = events.at(-1);
34
+ yield* expect("streams", textOf(events, "stdout") === `one
35
+ ` && textOf(events, "stderr") === `two
36
+ `, `the stream's chunks gave stdout ${show(textOf(events, "stdout"))} and stderr ${show(textOf(events, "stderr"))}`);
37
+ yield* expect("streams", exits.length === 1 && last?._tag === "BashExit" && Option.getOrNull(last.exitCode) === 4, `the stream had ${exits.length} BashExit events and ended with ${show(last?._tag)}`);
38
+ })),
39
+ bashCheck("output arrives while the command still runs, before its exit", "streaming", (fixture) => Effect.gen(function* () {
40
+ yield* Effect.addFinalizer(() => run(fixture, "touch go").pipe(Effect.ignore));
41
+ const started = yield* runUntil(fixture, "printf first; while [ ! -e go ]; do sleep 0.05; done; printf second", "first");
42
+ if (!started.arrived)
43
+ return yield* fail("streaming", "the first output did not arrive while the command ran (is output held until exit?)");
44
+ yield* run(fixture, "touch go");
45
+ const joined = yield* Fiber.join(started.fiber).pipe(Effect.timeoutOption("10 seconds"));
46
+ if (Option.isNone(joined))
47
+ return yield* fail("streaming", "the command did not end after it was let go");
48
+ const events = yield* Ref.get(started.events), last = events.at(-1);
49
+ yield* expect("streaming", textOf(events, "stdout") === "firstsecond" && last?._tag === "BashExit" && Option.getOrNull(last.exitCode) === 0 && events.filter((event) => event._tag === "BashExit").length === 1, `the stream gave ${show(textOf(events, "stdout"))}, ending with ${show(last)}`);
50
+ })),
51
+ bashCheck("stdin, text or bytes, is delivered and then closed; without stdin the command reads end-of-file at once", "stdin", (fixture) => Effect.gen(function* () {
52
+ const input = `line one
53
+ l\xEFne two \u2713
54
+ no newline at the end`, echoed = yield* run(fixture, "cat", { stdin: input });
55
+ yield* expect("stdin", echoed.stdout === input && Option.getOrNull(echoed.exitCode) === 0, `cat of stdin ended ${ended(echoed)}`);
56
+ const counted = yield* run(fixture, "wc -c", { stdin: Uint8Array.from([0, 255, 10, 128, 7]) });
57
+ yield* expect("stdin", counted.stdout.trim() === "5" && Option.getOrNull(counted.exitCode) === 0, `wc -c of five bytes of stdin ended ${ended(counted)}`);
58
+ const empty = yield* run(fixture, "cat; printf done").pipe(Effect.timeoutOption("10 seconds"));
59
+ yield* Option.match(empty, {
60
+ onNone: () => fail("stdin", "cat without stdin did not end in 10 seconds: its input was left open"),
61
+ onSome: (result) => expect("stdin", result.stdout === "done", `cat without stdin ended ${ended(result)}`)
62
+ });
63
+ })),
64
+ bashCheck("a command that ignores a large stdin and exits still yields its exit", "stdin-ignored", (fixture) => Effect.gen(function* () {
65
+ const result = yield* run(fixture, "printf ignored", { stdin: "x".repeat(1e6) });
66
+ yield* expect("stdin-ignored", result.termination === "exit" && Option.getOrNull(result.exitCode) === 0 && result.stdout === "ignored", `the command ended ${ended(result)}`);
67
+ })),
68
+ bashCheck("env is set over the parent environment", "env", (fixture) => Effect.gen(function* () {
69
+ const result = yield* run(fixture, `printf '%s|%s' "$EFFERENT_KIT_VALUE" "$EFFERENT_KIT_OVER"`, {
70
+ env: { EFFERENT_KIT_VALUE: "v\xE4lue with spaces", EFFERENT_KIT_OVER: "from env" },
71
+ inheritEnv: !0
72
+ });
73
+ yield* expect("env", result.stdout === "v\xE4lue with spaces|from env", `the command saw ${show(result.stdout)}`);
74
+ }), planted),
75
+ bashCheck("by default the parent environment passes through an allowlist: PATH and LC_* but not a planted secret; inheritEnv widens or empties it", "inherit-env", (fixture) => Effect.gen(function* () {
76
+ const probe = 'printf \'%s|%s|%s\' "${EFFERENT_KIT_SECRET-unset}" "${LC_EFFERENT_KIT-unset}" "${PATH:+path}"', settings = [Option.none(), Option.some(!0), Option.some(!1), Option.some(["EFFERENT_KIT_*"])], seen = yield* Effect.forEach(settings, (inheritEnv) => run(fixture, probe, Option.match(inheritEnv, { onNone: () => ({}), onSome: (value) => ({ inheritEnv: value }) })).pipe(Effect.map((result) => result.stdout))), expected = [
77
+ "unset|planted-locale|path",
78
+ "planted-secret|planted-locale|path",
79
+ "unset|unset|",
80
+ "planted-secret|unset|"
81
+ ], [byDefault, all, none, listed] = seen;
82
+ yield* expect("inherit-env", byDefault === expected[0] && all === expected[1], `by default the command saw ${show(byDefault)} and with inheritEnv true ${show(all)}; expected ${show(expected.slice(0, 2))}`);
83
+ yield* expect("inherit-env", none?.startsWith("unset|unset|") === !0 && listed?.startsWith("planted-secret|unset|") === !0, `with inheritEnv false the command saw ${show(none)}, with an allowlist of EFFERENT_KIT_* ${show(listed)}`);
84
+ }), planted),
85
+ bashCheck("a timeout kills the whole process group, a background child too, and reports termination timeout", "timeout", (fixture) => Effect.gen(function* () {
86
+ const result = yield* run(fixture, "sleep 30 & echo $! > child.pid; printf started; wait", { timeoutMs: 1000 });
87
+ yield* expect("timeout", result.termination === "timeout" && Option.isNone(result.exitCode) && result.stdout === "started" && result.durationMs < 1e4, `a command past its 1 s timeout ended ${ended(result)} after ${result.durationMs} ms`);
88
+ const status = yield* allGone(fixture, ["child.pid"]);
89
+ yield* expect("timeout", status === "gone", `the background child of the timed-out command is ${status}`);
90
+ })),
91
+ bashCheck("the command runs in cwd, and a missing cwd or one that is a file fails with CwdMissing", "cwd", (fixture) => Effect.gen(function* () {
92
+ yield* run(fixture, "printf here > marker.txt; printf x > afile");
93
+ const marker = yield* fixture.bash.exec(`cat ${quoted(`${fixture.root}/marker.txt`)}`, { cwd: "/" });
94
+ yield* expect("cwd", marker.stdout === "here", `a file written in cwd reads ${show(marker.stdout)} from its absolute path`);
95
+ const missing = yield* Effect.flip(fixture.bash.exec("true", { cwd: `${fixture.root}/missing` })), file = yield* Effect.flip(fixture.bash.exec("true", { cwd: `${fixture.root}/afile` }));
96
+ yield* expect("cwd", missing._tag === "BashError" && missing.reason === "CwdMissing" && file._tag === "BashError" && file.reason === "CwdMissing", `a missing cwd failed with ${show(missing)}, a file as cwd with ${show(file)}`);
97
+ })),
98
+ bashCheck("a command killed by a signal reports termination signal and the signal's name", "signal", (fixture) => Effect.gen(function* () {
99
+ const result = yield* run(fixture, "kill -TERM $$");
100
+ yield* expect("signal", result.termination === "signal" && Option.getOrNull(result.signal) === "SIGTERM" && Option.isNone(result.exitCode), `kill -TERM $$ ended ${ended(result)}`);
101
+ })),
102
+ bashCheck("UTF-8 written in two pieces arrives as whole characters", "utf8", (fixture) => Effect.gen(function* () {
103
+ const result = yield* run(fixture, "printf '\\303'; sleep 0.2; printf '\\251\\n'; printf '\\342\\234' >&2; sleep 0.2; printf '\\223' >&2");
104
+ yield* expect("utf8", result.stdout === `\xE9
105
+ ` && result.stderr === "\u2713", `a split \xE9 and \u2713 arrived as ${show(result.stdout)} and ${show(result.stderr)}`);
106
+ })),
107
+ bashCheck("interrupting the stream kills the command and what it started, and returns", "interrupt", (fixture) => Effect.gen(function* () {
108
+ const started = yield* runUntil(fixture, "sleep 30 & echo $! > child.pid; echo $$ > shell.pid; printf started; wait", "started");
109
+ if (!started.arrived)
110
+ return yield* fail("interrupt", "the command's output did not arrive");
111
+ const interrupted = yield* Fiber.interrupt(started.fiber).pipe(Effect.timeoutOption("10 seconds"));
112
+ if (Option.isNone(interrupted))
113
+ return yield* fail("interrupt", "interrupting the stream did not return in 10 seconds");
114
+ const status = yield* allGone(fixture, ["shell.pid", "child.pid"]);
115
+ yield* expect("interrupt", status === `gone
116
+ gone`, `after the interrupt the shell and its child are ${show(status.split(`
117
+ `))}`);
118
+ }))
119
+ ];
120
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ import { Effect } from "effect";
2
+ import type { Context, Scope } from "effect";
3
+ import type { ConformanceCheck } from "../conformance.entity.js";
4
+ import type { FileSystem } from "../ports/file-system.port.js";
5
+ type Fs = Context.Service.Shape<typeof FileSystem>;
6
+ /** One check's file system: the port, an empty directory to work in, and a way to make links. */
7
+ export interface FileSystemFixture {
8
+ readonly fs: Fs;
9
+ /** An absolute directory that exists and is empty; the check works only below it. */
10
+ readonly root: string;
11
+ /** Make a symbolic link at `path` to `target`, as `ln -s target path` does (the port has no call for it). */
12
+ readonly symlink: (target: string, path: string) => Effect.Effect<void, unknown>;
13
+ }
14
+ /**
15
+ * A FileSystem backend as the kit opens it: each run of `open` gives one
16
+ * check its fixture, a fresh empty `root` released (and removed) with its
17
+ * scope: a temporary directory on disk, a new tree in memory.
18
+ */
19
+ export interface FileSystemSubject {
20
+ readonly open: Effect.Effect<FileSystemFixture, unknown, Scope.Scope>;
21
+ }
22
+ /**
23
+ * The FileSystem contract as checks, run against a fresh fixture each:
24
+ * text and bytes round trips (UTF-8 included); `createParents`;
25
+ * `readBytes` ranges; `append`; `rename` (over an existing file, of a
26
+ * directory with what it holds, of a link and not its target, and which
27
+ * side a failure names); `stat` kinds, sizes and links; `exists`;
28
+ * `list` sorted by name with each entry's kind; `glob` with `*`, `**`, `?`,
29
+ * classes, braces, `exclude`, dot entries, `onlyFiles`, `limit` and a
30
+ * linked directory it must not enter; `mkdir` with and without `recursive`;
31
+ * `realPath` through links; the `remove` rules, and the path `remove` acts
32
+ * on (a trailing slash, a linked parent, a last `.` or `..`); and the
33
+ * `reason` and `path` of every failure. Each must finish within 30 seconds;
34
+ * none acts outside the fixture's root, even on a backend that gets the
35
+ * path wrong.
36
+ */
37
+ export declare const fileSystemConformance: (subject: FileSystemSubject) => ReadonlyArray<ConformanceCheck>;
38
+ export {};