Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-unnecessary-boolean-literal-compare": "warn",

"local/no-module-scoped-registry": "error",
"local/no-section-divider-comments": "error",
"local/no-yield-in-finally": "error",
"local/prefer-effection-result": "error"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export type { ComponentRegistration } from "./src/components/registration.ts";
export { DEFAULT_COMPONENT_DIRS, selectComponent } from "./src/components/select.ts";
export type { SelectOptions } from "./src/components/select.ts";
export { RESERVED_STRUCTURAL } from "./src/structural.ts";
export { collectFailures } from "./src/component-failures.ts";
export { captureErrors } from "./src/component-failures.ts";
export { parseMarkdownDefinition } from "./src/definition.ts";
export { compileDataUri, useDataUriCompiler } from "./src/data-uri-compiler.ts";
export { compileTempFile, useTempFileCompiler } from "./src/temp-file-compiler.ts";
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/answers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,12 @@ import type { ComponentElement, ErrorSegment, Json, Segment } from "./types.ts";
* arm holds that state, so it binds the recursion and passes it down; nothing
* here could reconstruct which expansion a region belongs to.
*/
type ExpandSegments = (segments: Segment[]) => Operation<Segment[]>;
/**
* The caller's own recursion. `owner` is the region the segments render into,
* when they render at all: the body writes there as it goes, while a matcher's
* template produces a value and keeps its own buffer.
*/
type ExpandSegments = (segments: Segment[], owner?: Segment[]) => Operation<Segment[]>;

const ANSWERS = "Answers";
const ANSWER = "Answer";
Expand Down Expand Up @@ -130,6 +135,8 @@ export function strayAnswerError(element: ComponentElement): ErrorSegment {
export function* expandAnswers(
element: ComponentElement,
expand: ExpandSegments,
/** The region the answered body renders into. */
owner: Segment[],
): Operation<Segment[]> {
for (const name of Object.keys({ ...element.props, ...element.expressions })) {
if (name !== "delegate") {
Expand Down Expand Up @@ -200,7 +207,8 @@ export function* expandAnswers(
{ at: "min" },
);

return yield* expand(body);
yield* expand(body, owner);
return [];
});
}

Expand Down
66 changes: 47 additions & 19 deletions packages/core/src/component-failures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,39 @@
*
* A component that fails fails the operation it is part of, like any other
* Effection work. Carrying on instead is a decision somebody makes: either the
* component says so about itself with `collectFailures()`, or a document says so
* about a region with `<CollectFailures>`. Both install the same middleware, so
* "the nearest collection boundary handles it" is one rule rather than two.
* component says so about itself with `captureErrors()`, or a document says so
* about a region with `<CaptureErrors>`. Both install the same middleware, so
* "the nearest capture boundary handles it" is one rule rather than two.
*
* Collection turns a failure into a diagnostic. It does not decide what happens
* to that diagnostic — the caller's ambient policy still settles it, so under
* documentation a collected failure still stops the document.
* Capture turns a failure into a diagnostic and marks that diagnostic as one a
* document asked for. It does not decide what happens to it — the caller's
* ambient policy still settles it, so a captured failure renders inside an
* `<Output>` region and still stops the document under documentation.
*/

import { Component, raise } from "./component-api.ts";
import { attributeCause } from "./errors.ts";
import { AmbientPolicyFrame, attributeCause, markCaptured } from "./errors.ts";
import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types.ts";
import type { Operation } from "effection";

/**
* Components that continue after failing, remembered by function identity.
* The brand a component wears to say it continues after failing.
*
* Identity rather than name: a repository component that happens to share a
* registered component's name is a different function and inherits nothing.
* It sits on the function object itself, so the answer is the component's own
* property rather than an entry in a table that outlives every run. Identity is
* what carries it: a repository component that happens to share a registered
* component's name is a different function object, wears no brand, and inherits
* nothing.
*
* Not enumerable, so a component that is copied, wrapped, or inspected does not
* carry the decision along by accident, and module-private rather than
* `Symbol.for`, so nothing outside this module can forge the brand.
*
* This is the one mark that is not run state: `captureErrors(fn)` runs while a
* component module is evaluated, outside any operation, and what it records is
* what an author declared about a definition.
*/
const collecting = new WeakSet<FunctionComponent>();
const CAPTURES = Symbol("executablemd.core.capturesErrors");

/**
* Continue after this component fails, reporting the failure as a diagnostic.
Expand All @@ -32,7 +44,7 @@ const collecting = new WeakSet<FunctionComponent>();
* so its identity and type survive:
*
* ```ts
* export default collectFailures(function* (props) {
* export default captureErrors(function* (props) {
* // body, requested content, retained work and teardown are all inside
* });
* ```
Expand All @@ -41,13 +53,13 @@ const collecting = new WeakSet<FunctionComponent>();
* invocation is being dismantled is collected too, and content the component
* projects is inside it.
*/
export function collectFailures<T extends FunctionComponent>(component: T): T {
collecting.add(component);
export function captureErrors<T extends FunctionComponent>(component: T): T {
Object.defineProperty(component, CAPTURES, { value: true, enumerable: false });
return component;
}

export function collectsFailures(component: FunctionComponent): boolean {
return collecting.has(component);
export function capturesErrors(component: FunctionComponent): boolean {
return Object.hasOwn(component, CAPTURES);
}

/**
Expand All @@ -59,16 +71,32 @@ export function collectsFailures(component: FunctionComponent): boolean {
* original failure is attributed as the diagnostic's cause, so what the
* component actually did remains reachable from the outside.
*/
export function useFailureCollection(): Operation<void> {
return Component.around({
export function* useFailures(): Operation<void> {
// The decision this boundary was opened under. A diagnostic raised under a
// policy something nested chose for itself — a component's own `<Output>`
// region — is not one this document asked to carry on past, and marking it
// would resume work that region's author gated behind the failure.
const boundary = yield* AmbientPolicyFrame.get();
yield* Component.around({
*handleFailure([failure], _next): Operation<ErrorSegment> {
const segment: ErrorSegment = {
type: "error",
message: `Function component ${failure.name} error: ${failure.error.message}`,
source: failure.name,
};
attributeCause(segment, failure.error);
yield* attributeCause(segment, failure.error);
return yield* raise(segment);
},
// Every diagnostic raised beneath the boundary is one the document asked to
// carry on past, not only the ones translated from a component failure: a
// region that captures errors captures the ones its own syntax reports too.
// Marking here rather than in `handleFailure` keeps that a property of the
// region, and delegating leaves the observation chain a single pass.
*raise([segment], next): Operation<ErrorSegment> {
if ((yield* AmbientPolicyFrame.get()) === boundary) {
yield* markCaptured(segment);
}
return yield* next(segment);
},
});
}
2 changes: 1 addition & 1 deletion packages/core/src/components/Elicit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default function* Elicit(props: Record<string, Json>): Operation<Json> {
const prepared = prepareElicitation(props.schema);
const prepared = yield* prepareElicitation(props.schema);

const message = yield* content();

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/components/File.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
import { randomUUID } from "node:crypto";
import { ensure, scoped } from "effection";
import type { Operation } from "effection";
import { collectFailures } from "../component-failures.ts";
import { captureErrors } from "../component-failures.ts";
import {
cwd,
ensureDir,
Expand Down Expand Up @@ -121,7 +121,7 @@ function* guard<T>(requested: string, verb: string, operation: Operation<T>): Op
}
}

export default collectFailures(function* (props: Record<string, Json>): Operation<string> {
export default captureErrors(function* (props: Record<string, Json>): Operation<string> {
const requested = String(props.path);
const admitted = yield* admissible(requested);

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/components/Glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@

import { isAbsolute } from "node:path";
import type { Operation } from "effection";
import { collectFailures } from "../component-failures.ts";
import { captureErrors } from "../component-failures.ts";
import { cwd, glob, stat } from "@executablemd/runtime";
import type { Json } from "../types.ts";
import { reason } from "./fs-diagnostics.ts";
Expand Down Expand Up @@ -74,7 +74,7 @@ export class GlobError extends Error {
}
}

export default collectFailures(function* (props: Record<string, Json>): Operation<string[]> {
export default captureErrors(function* (props: Record<string, Json>): Operation<string[]> {
const include = patterns("include", props.include);
const exclude = patterns("exclude", props.exclude);

Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/components/Parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

import type { Operation } from "effection";
import { collectFailures } from "../component-failures.ts";
import { captureErrors } from "../component-failures.ts";
import { content } from "../component-api.ts";
import type { Json } from "../types.ts";
import {
Expand Down Expand Up @@ -43,8 +43,8 @@ export const props = {
*/
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default collectFailures(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("Parse", props.schema);
export default captureErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = yield* compileParseSchema("Parse", props.schema);

const text = yield* content();

Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/components/SafeParse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/

import type { Operation } from "effection";
import { collectFailures } from "../component-failures.ts";
import { captureErrors } from "../component-failures.ts";
import { content } from "../component-api.ts";
import type { Json } from "../types.ts";
import {
Expand Down Expand Up @@ -70,8 +70,8 @@ export const returns = {
],
};

export default collectFailures(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("SafeParse", props.schema);
export default captureErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = yield* compileParseSchema("SafeParse", props.schema);

const text = yield* content();

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/components/TempDir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import { ensure, resource } from "effection";
import type { Operation } from "effection";
import { collectFailures } from "../component-failures.ts";
import { captureErrors } from "../component-failures.ts";
import { rm } from "@effectionx/fs";
import { API } from "@executablemd/runtime";
import { ReplayGuard, StaleInputError } from "@executablemd/durable-streams";
Expand Down Expand Up @@ -89,7 +89,7 @@ function refuseReplayInside(directory: string): Operation<void> {
});
}

export default collectFailures(function* (): Operation<string> {
export default captureErrors(function* (): Operation<string> {
if (yield* hasContent()) {
const directory = yield* useTemporaryDirectory();
yield* API.Env.around(
Expand Down
55 changes: 43 additions & 12 deletions packages/core/src/components/parse-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,42 @@ import { SchemaValidationError, normalizeIssues } from "../validate.ts";
import type { NormalizedIssue } from "../validate.ts";
import { parseJson, parseJsonObject } from "../json.ts";
import type { Json, JsonObject } from "../types.ts";
import { createContext } from "effection";
import type { Context, Operation } from "effection";

const ajv = new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
/**
* The compiler one execution uses, and the validators it built.
*
* Ajv memoizes every compile in a table of its own, keyed by the schema object
* it was handed — and a run brings fresh schema objects, so a single compiler
* would accumulate one entry per schema per run for the life of the process.
* The compiler therefore belongs to the run: created when a run installs it,
* reclaimed with everything it compiled when the run's scope ends.
*/
const ParseCompiler: Context<Ajv | undefined> = createContext<Ajv | undefined>(
"component.parseCompiler",
undefined,
);

function createCompiler(): Ajv {
return new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
}

/** Open the schema compiler for one execution. */
export function* useParseCompiler(): Operation<Ajv> {
const compiler = createCompiler();
yield* ParseCompiler.set(compiler);
return compiler;
}

/** A schema that could not be read or compiled. Raised before any child runs. */
export class ParseSchemaError extends Error {
Expand Down Expand Up @@ -65,8 +90,14 @@ function headline(componentName: string, issues: NormalizedIssue[]): string {
* draft-07 compilation, so a document can hold its schema in a code fence or in
* a binding and get identical behavior.
*/
export function compileParseSchema(componentName: string, schema: Json): ValidateFunction {
export function* compileParseSchema(
componentName: string,
schema: Json,
): Operation<ValidateFunction> {
const declaration = readSchema(componentName, schema);
// Without a run there is nothing to reclaim: the compiler lives exactly as
// long as this call.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// long as this call.

const compiler = (yield* ParseCompiler.get()) ?? createCompiler();

// Ajv does not reject an async schema — it compiles a validator that returns
// a promise. Reject it before and after compiling so validation stays
Expand All @@ -79,7 +110,7 @@ export function compileParseSchema(componentName: string, schema: Json): Validat

let validate: ValidateFunction;
try {
validate = ajv.compile(declaration);
validate = compiler.compile(declaration);
} catch (error) {
throw new ParseSchemaError(schemaFailure(componentName, error));
}
Expand Down
18 changes: 11 additions & 7 deletions packages/core/src/elicit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,19 +59,20 @@ export interface PreparedElicitation {
/**
* Normalize and compile a question's schema.
*
* Synchronous and effect-free: it either produces a question that can be asked
* or throws, and a caller that has not yet begun anything can still stop.
* Effect-free: it either produces a question that can be asked or throws, and a
* caller that has not yet begun anything can still stop. It reads the run's
* schema compiler, which is why it is an operation rather than a plain call.
*/
export function prepareElicitation(
export function* prepareElicitation(
schema: Json,
label: string = DEFAULT_LABEL,
): PreparedElicitation {
): Operation<PreparedElicitation> {
const declaration = readParseSchema(label, schema);

refuseUnsupportedNames(label, declaration);
refuseExternalReferences(label, declaration);

return { schema: declaration, validate: compileParseSchema(label, declaration), label };
return { schema: declaration, validate: yield* compileParseSchema(label, declaration), label };
}

/** Ask the configured provider, and judge what it returns. */
Expand All @@ -94,12 +95,15 @@ export function* runPreparedElicitation(
}

/** Compile and ask in one step, for a host with no ordering of its own. */
export function elicit(request: {
export function* elicit(request: {
message: string;
schema: Json;
label?: string;
}): Operation<Json> {
return runPreparedElicitation(prepareElicitation(request.schema, request.label), request.message);
return yield* runPreparedElicitation(
yield* prepareElicitation(request.schema, request.label),
request.message,
);
}

/**
Expand Down
Loading
Loading