Skip to content

Commit e0c4334

Browse files
authored
🔎 Share one read profile of File, Glob and Syntax (#762) (#789)
`xmd run` and every `host="run"` child stated one evaluation profile whose `read` table was the self-closing `<File />` alone. Plan information requests need a fragment to find files and read about the vocabulary it has, so the table is now File, Glob and canonical Syntax; the write table is unchanged and `read` still never reaches a write form. Glob is admitted as a capability, and admitting it did not create a second Glob. `src/glob-source.ts` now owns the props, the return contract, the source rules and the sanitized failure sentence, and both the ordinary component and the captured capability read them — so an author and an admitted fragment are held to one dialect and told the same thing about the same pattern. The capability declares what it binds, which is what makes `as` mandatory before the host's provider is asked to traverse anything, and it performs its search through the captured `globFiles` operation and captured working directory rather than `API.Files` and `Env.cwd`. Syntax is a component answer at canonical core's own origin, key and revision, not a capability: a protected name is one core already owns, and a second body for it would be a second answer to what the name means. Canonical resolution states core's identity for the exact answer it produced, from the innermost position, so middleware that returns that answer unchanged leaves the claim on it and middleware that replaces it returns something carrying no identity. Core answers for that one protected name — an entry hand-written at `<Evaluate>`'s canonical identity is refused, because being shown a vocabulary is a different grant from evaluation inside evaluation. `FragmentFileAccess` gains `globFiles`, captured, bound and revoked with the other operations. The workflow profile states it and admits no Glob entry, the way the ordinary run states `ensureDirectory` and admits no `<Dir>`. The architecture's complete #762 design is reconciled with the accepted issue contract ahead of the remaining two PRs. Information requests carry no local deadline and no rendered-output size limit, so the ten-second deadline, the 65,536-byte findings budget, their refusal messages, `EvaluationLimitError`, the bounds fingerprint and the bounded streaming meter are removed rather than left as inactive machinery. What Plan composes around the public throwing `<Evaluate>` is narrow typed recovery of one namespaced candidate class after teardown, where a cleanup failure always wins over a retry. PI6, PI10 and PI11 now describe cleanup before the next turn, the recoverable-versus-terminal split, and every built-in construct with untaken-branch preflight. Delivery is stated as the agreed three PRs — shared read profile, generated structural constructs, then the Plan loop — with request-aware Syntax rewriting left to #787 and a general `<Timeout>` to #788.
1 parent 426df00 commit e0c4334

15 files changed

Lines changed: 1033 additions & 265 deletions

‎architecture.md‎

Lines changed: 117 additions & 122 deletions
Large diffs are not rendered by default.

‎packages/cli/src/evaluation-profile.ts‎

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,18 @@
88
*
99
* ## The two tables
1010
*
11-
* `read` is core's self-closing `<File />` and nothing else. `write` is core's
12-
* paired `<File>…</File>` and self-closing `<File.Delete />`. A fragment run by
13-
* `xmd run` therefore reaches the caller's own filesystem through the Files
14-
* provider this command installed, and reaches nothing else at all: no network
15-
* read, no process, no repository, no Git, no credential and no agent.
11+
* `read` is core's self-closing `<File />`, its self-closing `<Glob />` and
12+
* canonical `<Syntax />`. `write` is core's paired `<File>…</File>` and
13+
* self-closing `<File.Delete />`. A fragment run by `xmd run` therefore reaches
14+
* the caller's own filesystem through the Files provider this command
15+
* installed, and reaches nothing else at all: no network read, no process, no
16+
* repository, no Git, no credential and no agent.
17+
*
18+
* Reading and writing stay separate spellings of one name. A selection of
19+
* `read` admits `<File />` and not `<File>…</File>`, so a fragment that asks to
20+
* search and read cannot also write, and `<Syntax />` describing a component
21+
* says only what the vocabulary holds — a name it renders is not a name the
22+
* selection admits.
1623
*
1724
* `<Fetch>` is absent rather than present-and-bounded. An unbounded network
1825
* read is a decision this command does not make on a document's behalf, and
@@ -32,7 +39,13 @@
3239
* spelling, so there is no document written against it to keep working.
3340
*/
3441

35-
import { fileDeleteEntry, fileReadEntry, fileWriteEntry } from "@executablemd/core/host";
42+
import {
43+
fileDeleteEntry,
44+
fileReadEntry,
45+
fileWriteEntry,
46+
globReadEntry,
47+
syntaxReadEntry,
48+
} from "@executablemd/core/host";
3649
import type {
3750
ExecutionInstallation,
3851
FragmentEvaluationInput,
@@ -49,15 +62,21 @@ import { cwd, hostFilesHandler } from "@executablemd/runtime";
4962
* nothing a document, a repository component or middleware installs is between
5063
* a fragment and this instance.
5164
*
52-
* Only the five operations a fragment can name are copied across. The handler
53-
* also offers globbing and temporary directories; an admitted fragment has
54-
* neither, and not copying them is what makes that true.
65+
* Only the six operations a fragment can name are copied across. The handler
66+
* also offers temporary directories; an admitted fragment has none, and not
67+
* copying it is what makes that true.
5568
*/
5669
function ordinaryFiles(): FragmentFileAccess {
5770
const handler = hostFilesHandler();
5871
return {
5972
checkFilePath: (input) => handler.checkFilePath(input),
6073
readTextFile: (input) => handler.readTextFile(input),
74+
globFiles: (input) =>
75+
handler.globFiles({
76+
cwd: input.cwd,
77+
include: [...input.include],
78+
exclude: [...input.exclude],
79+
}),
6180
writeTextFile: (input) => handler.writeTextFile(input),
6281
deleteFile: (input) => handler.deleteFile(input),
6382
ensureDirectory: (input) => handler.ensureDirectory(input),
@@ -72,7 +91,7 @@ function ordinaryFiles(): FragmentFileAccess {
7291
/** The ceiling `xmd run` and its run children state. */
7392
export function ordinaryEvaluationProfile(): FragmentEvaluationInput {
7493
return {
75-
read: [fileReadEntry()],
94+
read: [fileReadEntry(), globReadEntry(), syntaxReadEntry()],
7695
write: [fileWriteEntry(), fileDeleteEntry()],
7796
files: ordinaryFiles(),
7897
};

‎packages/cli/tests/evaluate-component.test.ts‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,35 @@ describe("Tier FE — the ordinary run profile", () => {
7474
);
7575
});
7676

77+
it("FE21: a fragment searches and reads through the profile's own operations", function* () {
78+
yield* useWorkspace(
79+
{
80+
"notes.md": NOTE,
81+
"doc.md":
82+
`<Evaluate text={'<Glob include={["*.md"]} as="found" />\\n<Json value={found} />\\n'} ` +
83+
`as="answer" />\n\n<Json value={answer} />\n`,
84+
},
85+
function* (dir) {
86+
const result = yield* runCli(["run", join(dir, "doc.md")], { cwd: dir }).join();
87+
expect(result.code).toBe(0);
88+
expect(result.stdout).toContain("notes.md");
89+
},
90+
);
91+
});
92+
7793
it("FE21: a fragment cannot reach an operation the ordinary profile withheld", function* () {
7894
yield* useWorkspace(
7995
{
8096
"notes.md": NOTE,
81-
"doc.md": `<Evaluate text={'<Glob pattern="*.md" />\\n'} />\n`,
97+
"doc.md": `<Evaluate text={'<Fetch url="https://example.test/notes" />\\n'} />\n`,
8298
},
8399
function* (dir) {
84100
const result = yield* runCli(["run", join(dir, "doc.md")], { cwd: dir }).join();
85-
// `<Glob>` is an ordinary component of this run and is not a name the
86-
// fragment has: the ceiling is the profile's, not the document's.
101+
// `<Fetch>` is an ordinary component of this run and is not a name the
102+
// fragment has: the ceiling is the profile's, not the document's. An
103+
// unbounded network read is a decision `xmd run` does not make on a
104+
// document's behalf, so the identity is absent rather than admitted
105+
// with an empty ceiling.
87106
expect(result.code).not.toBe(0);
88107
expect(`${result.stdout}${result.stderr}`).toContain("did not admit");
89108
},

‎packages/cli/tests/testing-execution-host.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,53 @@ describe("nested execution under the production run host", () => {
408408
expect(ordinary.stdout).not.toContain("No long-form documentation");
409409
});
410410

411+
/**
412+
* The run profile's read table reaches a child, whichever command hosts it.
413+
*
414+
* `host="run"` means the run profile, and the evaluation ceiling is part of
415+
* it: a child of `xmd test` evaluates a generated fragment under exactly what
416+
* `xmd run` states. So the same three read entries are here — the file, the
417+
* search and the vocabulary — and the write table is still not what `read`
418+
* selects.
419+
*/
420+
it("gives a run child the ordinary read profile: File, Glob and Syntax", function* () {
421+
const project = yield* useProject({
422+
"notes.md": doc("the retained note"),
423+
"reader.md": doc(
424+
`<Evaluate text={'<Glob include={["notes.md"]} as="paths" />\\n` +
425+
`<File path="notes.md" as="note" />\\n` +
426+
`<Json value={{ paths, note }} />\\n'} as="answer" />`,
427+
"",
428+
"<Json value={answer} />",
429+
),
430+
"writer.md": doc(
431+
`<Evaluate text={'<File path="made.md">written by the fragment</File>\\n'} ` +
432+
`allow={["read"]} />`,
433+
),
434+
"README.md": doc(
435+
'<Test name="run child reads">',
436+
'<Execution host="run" target="./reader.md" as="child">',
437+
'<CollectOutput as="output" />',
438+
"",
439+
'<AssertStringIncludes actual={output} expected="notes.md" />',
440+
'<AssertStringIncludes actual={output} expected="the retained note" />',
441+
"</Execution>",
442+
"</Test>",
443+
"",
444+
'<Test name="run child cannot write under read">',
445+
'<Execution host="run" target="./writer.md" as="child">',
446+
"<AssertEquals actual={child.result.ok} expected={false} />",
447+
"</Execution>",
448+
"</Test>",
449+
),
450+
});
451+
const result = yield* runCli(["test", "README.md"], { cwd: project }).join();
452+
expect(result.code).toBe(0);
453+
// The refused write left nothing behind, which is what "before any
454+
// operation" means for a selection that never held the write table.
455+
expect(yield* readdir(project)).not.toContain("made.md");
456+
});
457+
411458
it("refuses <Execution> outside a canonical <Test>", function* () {
412459
const project = yield* useProject({
413460
"child.md": doc("child"),

‎packages/core/host.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export type {
119119
FragmentFetchAccess,
120120
FragmentFileAccess,
121121
FragmentPath,
122+
FragmentSearch,
122123
FragmentWrite,
123124
} from "./src/fragment-capabilities.ts";
124125
/**
@@ -136,8 +137,10 @@ export { detachHeaders, detachStatus } from "./src/fetch-response.ts";
136137
*
137138
* Constructors rather than a table a host assembles from `CORE_REGISTRY`,
138139
* because each of them states a constraint the registry does not hold: which
139-
* spelling of `<File>` is being admitted, and that admitting `<Fetch>` requires
140-
* the exact requests it may perform.
140+
* spelling of `<File>` is being admitted, that admitting `<Fetch>` requires the
141+
* exact requests it may perform, and that `<Syntax />` is admitted as the
142+
* answer canonical resolution gives for it rather than as a body core supplies
143+
* a second time.
141144
*/
142145
export {
143146
directoryEntry,
@@ -146,6 +149,8 @@ export {
146149
fileDeleteEntry,
147150
fileReadEntry,
148151
fileWriteEntry,
152+
globReadEntry,
153+
syntaxReadEntry,
149154
} from "./src/evaluation-profile.ts";
150155
/**
151156
* The symbols a host's profile describes, when they are not the ones the

‎packages/core/src/components/Glob.ts‎

Lines changed: 23 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
* segment is `..` — is refused rather than quietly contributing nothing: an
1515
* empty result is the answer to "there are no such files", and it must not also
1616
* be the answer to "that pattern was a mistake". The same stage refuses an
17-
* empty pattern, which matches nothing by construction.
17+
* empty pattern, which matches nothing by construction. Those rules and the
18+
* sanitized sentence a failed search produces are `glob-source.ts`, shared with
19+
* the Glob capability an evaluation profile admits, so one document and one
20+
* generated fragment are held to one dialect.
1821
*
1922
* Everything else about matching belongs to the `API.Files` provider, which is
2023
* the dialect and owns the whole search. This component adds no syntax of its
@@ -43,29 +46,17 @@
4346
import type { Operation } from "effection";
4447
import { printErrors } from "../component-failures.ts";
4548
import { cwd, parseFilesFailure } from "@executablemd/runtime";
46-
import type { FilesFailureData } from "@executablemd/runtime";
4749
import { globFiles } from "../files.ts";
50+
import { GLOB_PROPS, GLOB_RETURNS, globFailure, globPatterns } from "../glob-source.ts";
4851
import type { Json } from "../types.ts";
49-
import { reason } from "./fs-error-phrases.ts";
5052

51-
export const props = {
52-
type: "object",
53-
properties: {
54-
include: { type: "array", items: { type: "string" }, minItems: 1 },
55-
exclude: { type: "array", items: { type: "string" }, default: [] },
56-
},
57-
required: ["include"],
58-
additionalProperties: false,
59-
};
53+
export const props = GLOB_PROPS;
6054

6155
/**
6256
* Declaring `returns` is what makes this a value component: the engine requires
6357
* `as`, renders nothing, and validates what comes back (§6.10).
6458
*/
65-
export const returns = {
66-
type: "array",
67-
items: { type: "string" },
68-
};
59+
export const returns = GLOB_RETURNS;
6960

7061
/** A pattern that cannot be used, or a directory that cannot be searched. */
7162
export class GlobError extends Error {
@@ -76,98 +67,24 @@ export class GlobError extends Error {
7667
}
7768

7869
export default printErrors(function* (props: Record<string, Json>): Operation<string[]> {
79-
const include = patterns("include", props.include);
80-
const exclude = patterns("exclude", props.exclude);
81-
82-
const found = yield* globFiles({ cwd: yield* cwd(), include, exclude });
83-
if (!found.ok) {
84-
throw failed(parseFilesFailure(found.error), [...include, ...exclude]);
85-
}
86-
return found.value;
87-
});
88-
89-
/**
90-
* The patterns a prop holds, checked for the two things that make one unusable.
91-
*
92-
* Prop validation has already established an array of strings, so the shape is
93-
* re-read rather than asserted (`as` would claim it instead) and a value that
94-
* somehow is not one contributes nothing. What validation cannot express is
95-
* *meaning*: patterns match paths relative to `Env.cwd`, so an absolute pattern
96-
* and one that starts by leaving cannot match anything a search can produce.
97-
*
98-
* Only a whole `..` first segment leaves. `..notes.md` is an ordinary name, and
99-
* a `..` further along — `docs/../*.md` — is a path the search never generates,
100-
* so it matches nothing for the ordinary reason and needs no special refusal.
101-
*/
102-
function patterns(prop: string, value: Json | undefined): string[] {
103-
if (!Array.isArray(value)) {
104-
return [];
70+
const include = globPatterns("include", props.include);
71+
if (!include.ok) {
72+
throw new GlobError(include.error.message);
10573
}
106-
107-
const found: string[] = [];
108-
for (const pattern of value) {
109-
if (typeof pattern !== "string") {
110-
continue;
111-
}
112-
if (pattern.length === 0) {
113-
throw new GlobError(
114-
`${prop} holds an empty pattern, which matches nothing; ` +
115-
"give a pattern relative to the working directory.",
116-
);
117-
}
118-
if (absolute(pattern)) {
119-
throw new GlobError(
120-
`${prop} pattern "${pattern}" is absolute; ` +
121-
"give a pattern relative to the working directory.",
122-
);
123-
}
124-
if (pattern === ".." || pattern.startsWith("../")) {
125-
throw new GlobError(`${prop} pattern "${pattern}" reaches outside the working directory.`);
126-
}
127-
found.push(pattern);
74+
const exclude = globPatterns("exclude", props.exclude);
75+
if (!exclude.ok) {
76+
throw new GlobError(exclude.error.message);
12877
}
129-
return found;
130-
}
13178

132-
/**
133-
* Whether a pattern names an absolute location.
134-
*
135-
* Decided from the pattern's own grammar rather than the running platform's.
136-
* Patterns match POSIX-relative paths on every host — that is what makes one
137-
* document mean one thing everywhere — so a leading `/` is absolute wherever
138-
* this runs, and so is a drive-letter prefix, which is absolute on the host that
139-
* has drives and matches nothing on the hosts that do not. A leading backslash
140-
* is left alone: in this dialect it escapes the character after it.
141-
*/
142-
function absolute(pattern: string): boolean {
143-
return pattern.startsWith("/") || /^[A-Za-z]:[\\/]/.test(pattern);
144-
}
145-
146-
/**
147-
* One sanitized sentence for a failed search.
148-
*
149-
* The two questions an author can act on are separated from the rest. A working
150-
* directory that is missing or is a file is something about the document's own
151-
* environment; a pattern the dialect cannot compile — an unterminated character
152-
* class — is something about the document's own text. Which pattern it was does
153-
* not survive the provider boundary, so the sentence lists the candidates
154-
* instead of naming one. They are the document's own text.
155-
*
156-
* Everything else names no path. What failed is the working directory or
157-
* something under it, and both are absolute paths the document did not write
158-
* (§1.2).
159-
*/
160-
function failed(data: FilesFailureData | undefined, candidates: string[]): GlobError {
161-
if (data?.phase === "target" && data.reason === "missing") {
162-
return new GlobError("the working directory does not exist.");
163-
}
164-
if (data?.phase === "target" && data.reason === "not-directory") {
165-
return new GlobError("the working directory is not a directory.");
166-
}
167-
if (data?.phase === "pattern") {
168-
return new GlobError(
169-
`one of these patterns cannot be used: ${candidates.map((p) => `"${p}"`).join(", ")}.`,
79+
const found = yield* globFiles({
80+
cwd: yield* cwd(),
81+
include: include.value,
82+
exclude: exclude.value,
83+
});
84+
if (!found.ok) {
85+
throw new GlobError(
86+
globFailure(parseFilesFailure(found.error), [...include.value, ...exclude.value]),
17087
);
17188
}
172-
return new GlobError(`cannot search the working directory: ${reason(data?.reason)}.`);
173-
}
89+
return found.value;
90+
});

0 commit comments

Comments
 (0)