@turri-dev/cli 0.1.13 → 0.1.14-next.678847f
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.
- package/dist/extension.d.ts +5 -1
- package/dist/extension.js +28 -12
- package/dist/host.d.ts +6 -2
- package/dist/host.js +10 -2
- package/dist/new-project.d.ts +7 -0
- package/dist/new-project.js +37 -0
- package/dist/plain/concepts.d.ts +18 -0
- package/dist/plain/concepts.js +41 -14
- package/dist/plain/files.d.ts +2 -0
- package/dist/plain/files.js +7 -3
- package/dist/plain/graph.d.ts +68 -0
- package/dist/plain/graph.js +136 -0
- package/dist/plain/module-concepts.d.ts +32 -0
- package/dist/plain/module-concepts.js +79 -0
- package/dist/plain/parser.d.ts +9 -0
- package/dist/plain/parser.js +36 -22
- package/dist/plain/stdlib.d.ts +2 -0
- package/dist/plain/stdlib.js +5 -1
- package/dist/regenerate/requires.d.ts +2 -0
- package/dist/regenerate/requires.js +2 -1
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +1 -1
- package/dist/skills.d.ts +10 -0
- package/dist/skills.js +20 -3
- package/dist/stack.d.ts +10 -2
- package/dist/stack.js +17 -11
- package/dist/stdlib.d.ts +16 -0
- package/dist/stdlib.js +27 -9
- package/docs/authoring-plain.md +4 -4
- package/docs/hosting.md +24 -0
- package/docs/installation.md +1 -1
- package/docs/parser.md +1 -0
- package/docs/skills-and-gates.md +1 -1
- package/docs/using.md +3 -1
- package/genome/rules/import-modules.md +1 -0
- package/genome/rules/integration-embedded.md +1 -1
- package/genome/skills/break-down-func-spec/SKILL.md +1 -1
- package/genome/skills/refactor-module/SKILL.md +2 -2
- package/package.json +1 -1
- package/genome/skills/check-plain-env/SKILL.md +0 -285
- package/genome/skills/create-import-module/SKILL.md +0 -104
- package/genome/skills/create-requires-module/SKILL.md +0 -109
package/dist/extension.d.ts
CHANGED
|
@@ -24,7 +24,11 @@ dialogs?: AskUserIO): AskUser;
|
|
|
24
24
|
* pick from, several at once, "Something else…" to type instead, "Chat about this" to talk it over in
|
|
25
25
|
* the chat first - so a question with a handful of answers is picked, not typed.
|
|
26
26
|
*/
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* ask_user, for the conversation. `afterAnswer` may add to what an answer tells the model - a skill the person picked,
|
|
29
|
+
* to start at once.
|
|
30
|
+
*/
|
|
31
|
+
export declare function chatAskUserTool(dialogs?: AskUserIO, afterAnswer?: (answer: string) => string | undefined): ReturnType<typeof createAskUserTool>;
|
|
28
32
|
/**
|
|
29
33
|
* What a host that runs turri's conversation itself - a desktop app, an editor - is told and asked, where the terminal
|
|
30
34
|
* would draw: the conversation's jobs and its work loop as they are made and as they change, and the dialogs its
|
package/dist/extension.js
CHANGED
|
@@ -25,6 +25,7 @@ import { createDeployState, createDeployTools } from "./release-tools.js";
|
|
|
25
25
|
import { runReleaseAgent } from "./release-agent.js";
|
|
26
26
|
import { createSteeringStore, EVERY_MODULE, moduleNameOf, queueSectionOf, steeringFor } from "./regenerate/steer.js";
|
|
27
27
|
import { askedForCommandLine, refusal, selfInvocation, TOOLS_NOT_CLI } from "./self-invocation.js";
|
|
28
|
+
import { NEW_PROJECT } from "./new-project.js";
|
|
28
29
|
import { describeChoices, describeResolution, modelFor, resolveModel } from "./regenerate/engine.js";
|
|
29
30
|
import { checkPlainFile, checkPlainTargets, formatCheckResults } from "./plain/check.js";
|
|
30
31
|
import { whyFailed } from "./regenerate/diagnose-job.js";
|
|
@@ -57,7 +58,7 @@ import { describeStdlib } from "./stdlib.js";
|
|
|
57
58
|
import { peekKeyListener, runAsk } from "./ask.js";
|
|
58
59
|
import { QuestionDialog } from "./question-dialog.js";
|
|
59
60
|
import { registerStacks } from "./stacks.js";
|
|
60
|
-
import { catalogueLine, createCommandGate, discoverSkills, leftOutNote, startedByCommandOnly } from "./skills.js";
|
|
61
|
+
import { catalogueLine, chosenCommand, createCommandGate, startPicked, discoverSkills, leftOutNote, startedByCommandOnly } from "./skills.js";
|
|
61
62
|
import { supersededByTurri } from "./chat-skills.js";
|
|
62
63
|
import { conversationIdentity } from "./identity.js";
|
|
63
64
|
import { setSurface, surfaceOf } from "./surface.js";
|
|
@@ -170,7 +171,11 @@ function hookPeekKey(ctx) {
|
|
|
170
171
|
* pick from, several at once, "Something else…" to type instead, "Chat about this" to talk it over in
|
|
171
172
|
* the chat first - so a question with a handful of answers is picked, not typed.
|
|
172
173
|
*/
|
|
173
|
-
|
|
174
|
+
/**
|
|
175
|
+
* ask_user, for the conversation. `afterAnswer` may add to what an answer tells the model - a skill the person picked,
|
|
176
|
+
* to start at once.
|
|
177
|
+
*/
|
|
178
|
+
export function chatAskUserTool(dialogs, afterAnswer) {
|
|
174
179
|
let conversation;
|
|
175
180
|
const tool = createAskUserTool((request) => (conversation !== undefined ? askUserVia(conversation, () => { }, undefined, dialogs)(request) : Promise.resolve("No user is available to answer.")), undefined, undefined, undefined, "reply");
|
|
176
181
|
return {
|
|
@@ -181,7 +186,10 @@ export function chatAskUserTool(dialogs) {
|
|
|
181
186
|
],
|
|
182
187
|
async execute(...args) {
|
|
183
188
|
conversation = args[4];
|
|
184
|
-
|
|
189
|
+
const result = await tool.execute(...args);
|
|
190
|
+
const answer = result.details?.answer;
|
|
191
|
+
const note = answer !== undefined && answer !== "" ? afterAnswer?.(answer) : undefined;
|
|
192
|
+
return note !== undefined ? { ...result, content: [...result.content, { type: "text", text: note }] } : result;
|
|
185
193
|
},
|
|
186
194
|
};
|
|
187
195
|
}
|
|
@@ -313,7 +321,7 @@ function turriExtension(pi, host) {
|
|
|
313
321
|
}
|
|
314
322
|
}
|
|
315
323
|
return {
|
|
316
|
-
systemPrompt: conversationSystemPrompt(event.systemPrompt, host, [TOOLS_NOT_CLI, projectConfigBlock(cwd), machineBlock(cwd, machine.now()), rulesBlock()]),
|
|
324
|
+
systemPrompt: conversationSystemPrompt(event.systemPrompt, host, [TOOLS_NOT_CLI, NEW_PROJECT, projectConfigBlock(cwd), machineBlock(cwd, machine.now()), rulesBlock()]),
|
|
317
325
|
};
|
|
318
326
|
});
|
|
319
327
|
// The conversation's agent does not run turri again through its shell, unless the person
|
|
@@ -433,7 +441,17 @@ function turriExtension(pi, host) {
|
|
|
433
441
|
registerBranding(pi, discoveredSkills);
|
|
434
442
|
registerInterviewLog(pi);
|
|
435
443
|
registerJobTools(pi, jobs, cwd);
|
|
436
|
-
|
|
444
|
+
// The skills the person starts themselves - brood, spawn, bud: each has its command, and cultivate starts it only
|
|
445
|
+
// once its command is typed, or picked among the options the conversation offers with ask_user.
|
|
446
|
+
const gate = createCommandGate();
|
|
447
|
+
// A skill picked is started at once, with what the person asked for: they never type its command after picking it.
|
|
448
|
+
registerCheckPlain(pi, cwd, host.dialogs, (answer) => {
|
|
449
|
+
const chosen = chosenCommand(answer, discoveredSkills.skills);
|
|
450
|
+
if (chosen === undefined)
|
|
451
|
+
return undefined;
|
|
452
|
+
gate.typed(chosen);
|
|
453
|
+
return startPicked(chosen, lastUserMessage);
|
|
454
|
+
});
|
|
437
455
|
registerRegenerate(pi, cwd, jobs);
|
|
438
456
|
registerDrift(pi, cwd, jobs);
|
|
439
457
|
registerGlimpse(pi, cwd, jobs);
|
|
@@ -449,10 +467,8 @@ function turriExtension(pi, host) {
|
|
|
449
467
|
return;
|
|
450
468
|
const catalogue = skills.map(catalogueLine).join("\n");
|
|
451
469
|
const pendingNote = leftOutNote(discoveredSkills);
|
|
452
|
-
// The
|
|
453
|
-
//
|
|
454
|
-
// run it with what follows as its context.
|
|
455
|
-
const gate = createCommandGate();
|
|
470
|
+
// The command puts what was typed in the chat, as the person's message, for the model to run it with what follows as
|
|
471
|
+
// its context.
|
|
456
472
|
for (const skill of skills.filter((s) => s.commandOnly)) {
|
|
457
473
|
pi.registerCommand(skill.name, {
|
|
458
474
|
description: `Start ${skill.name}${skill.steps.length > 1 ? ` - ${skill.steps.map((step) => step.name).join(" → ")}` : ""}: /${skill.name} [what you want]`,
|
|
@@ -472,7 +488,7 @@ function turriExtension(pi, host) {
|
|
|
472
488
|
promptSnippet: "cultivate: run a gated skill - its steps and review gates enforced by turri",
|
|
473
489
|
promptGuidelines: [
|
|
474
490
|
"When a request matches a skill listed in cultivate's description, call cultivate instead of doing the work yourself. Doing it by hand skips the review gates.",
|
|
475
|
-
"A skill marked as started only by the person runs only from its command: a message that is its command - /spawn a small server - is the person starting it, so
|
|
491
|
+
"A skill marked as started only by the person runs only from its command: a message that is its command - /spawn a small server - is the person starting it, and so is an option starting with it that they picked in your ask_user - /brood - an interview. Call cultivate for it at once, with what they said as context. Never call cultivate for one otherwise; for a new project, offer the choice as <new-project> says.",
|
|
476
492
|
// pi lists .agents/skills among its own skills too: read and followed by hand, a skill's gates would be skipped.
|
|
477
493
|
"A skill cultivate lists may also be among your own skills, from the project's .agents/skills: run it with cultivate all the same, never by reading its SKILL.md and following it.",
|
|
478
494
|
"cultivate returns the reviewed artifact and a trace of every gate decision. Report the artifact to the user; do not rewrite it.",
|
|
@@ -2063,9 +2079,9 @@ function jobLines(all) {
|
|
|
2063
2079
|
* so it needs no external tool, no API key and no credits - and it is the only honest way
|
|
2064
2080
|
* to answer "is this spec valid?" without generating anything.
|
|
2065
2081
|
*/
|
|
2066
|
-
function registerCheckPlain(pi, cwd, dialogs) {
|
|
2082
|
+
function registerCheckPlain(pi, cwd, dialogs, afterAnswer) {
|
|
2067
2083
|
pi.registerTool(checkPlainTool(cwd));
|
|
2068
|
-
pi.registerTool(chatAskUserTool(dialogs));
|
|
2084
|
+
pi.registerTool(chatAskUserTool(dialogs, afterAnswer));
|
|
2069
2085
|
// The web, for the conversation too - a library's documentation, an API, an error; curl is its
|
|
2070
2086
|
// bash. Run here, like the rendering agents' (see render/web.ts).
|
|
2071
2087
|
for (const tool of createWebTools())
|
package/dist/host.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* turri for a host that runs it itself - a desktop app, an editor - imported as `@turri-dev/cli/host`: the
|
|
3
3
|
* conversation as pi's extension, with the host told of its jobs and asking its questions (createTurriExtension), and
|
|
4
|
-
* what a host shows beside it - a spec parsed, checked and coloured,
|
|
5
|
-
* spec change would plan. It is turri's own code, as the terminal runs it; a 0.x release may change any of it.
|
|
4
|
+
* what a host shows beside it - a spec parsed, checked and coloured, its concepts and links, the standard library, how
|
|
5
|
+
* far each functional spec is built, the work a spec change would plan. It is turri's own code, as the terminal runs it; a 0.x release may change any of it.
|
|
6
6
|
*/
|
|
7
7
|
export { createTurriExtension, type TurriHost } from "./extension.ts";
|
|
8
8
|
export type { AskUserIO } from "./ask.ts";
|
|
@@ -18,6 +18,10 @@ export { checkPlainFile, type CheckResult } from "./plain/check.ts";
|
|
|
18
18
|
export { plainFileParser } from "./plain/parser.ts";
|
|
19
19
|
export { FUNCTIONAL_REQUIREMENTS, iterateFunctionalSpecs } from "./plain/spec.ts";
|
|
20
20
|
export { highlightLines, roleOf, type PlainRole } from "./plain/highlight.ts";
|
|
21
|
+
export { conceptsOf, type ModuleConcept } from "./plain/module-concepts.ts";
|
|
22
|
+
export { conceptBeingTyped, findConcepts } from "./plain/concepts.ts";
|
|
23
|
+
export { plainGraph, type PlainConceptNode, type PlainGraph, type PlainItemNode, type PlainLink, type PlainModuleNode, type PlainSection } from "./plain/graph.ts";
|
|
24
|
+
export { isStack, STDLIB_KINDS, stdlibTemplates, type StdlibKind, type StdlibTemplate } from "./stdlib.ts";
|
|
21
25
|
export { createWorkLoop, describeModules, functionalityCompletion, promptOf, type DriftReport, type ModuleStatus, type WorkLoop } from "./regenerate/loop.ts";
|
|
22
26
|
export { createJobRegistry, defaultParallelJobs, type Job, type JobRegistry } from "./regenerate/jobs.ts";
|
|
23
27
|
export { describeTurnIncident, onModelEvent, type TranscriptEntry } from "./regenerate/activity.ts";
|
package/dist/host.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* turri for a host that runs it itself - a desktop app, an editor - imported as `@turri-dev/cli/host`: the
|
|
3
3
|
* conversation as pi's extension, with the host told of its jobs and asking its questions (createTurriExtension), and
|
|
4
|
-
* what a host shows beside it - a spec parsed, checked and coloured,
|
|
5
|
-
* spec change would plan. It is turri's own code, as the terminal runs it; a 0.x release may change any of it.
|
|
4
|
+
* what a host shows beside it - a spec parsed, checked and coloured, its concepts and links, the standard library, how
|
|
5
|
+
* far each functional spec is built, the work a spec change would plan. It is turri's own code, as the terminal runs it; a 0.x release may change any of it.
|
|
6
6
|
*/
|
|
7
7
|
// The conversation.
|
|
8
8
|
export { createTurriExtension } from "./extension.js";
|
|
@@ -25,6 +25,14 @@ export { checkPlainFile } from "./plain/check.js";
|
|
|
25
25
|
export { plainFileParser } from "./plain/parser.js";
|
|
26
26
|
export { FUNCTIONAL_REQUIREMENTS, iterateFunctionalSpecs } from "./plain/spec.js";
|
|
27
27
|
export { highlightLines, roleOf } from "./plain/highlight.js";
|
|
28
|
+
// Its concepts: those a module can name, with what each is and where it is defined, and those written in a piece of text.
|
|
29
|
+
export { conceptsOf } from "./plain/module-concepts.js";
|
|
30
|
+
export { conceptBeingTyped, findConcepts } from "./plain/concepts.js";
|
|
31
|
+
// A project's ***plain as a graph: its modules and their links both ways, each item of their sections, the concepts
|
|
32
|
+
// each names and where each is defined.
|
|
33
|
+
export { plainGraph } from "./plain/graph.js";
|
|
34
|
+
// The standard library: every template, what it is, what it imports, and its conformance template or stack.
|
|
35
|
+
export { isStack, STDLIB_KINDS, stdlibTemplates } from "./stdlib.js";
|
|
28
36
|
// The work: what is built, what is left, and the jobs that build it.
|
|
29
37
|
export { createWorkLoop, describeModules, functionalityCompletion, promptOf } from "./regenerate/loop.js";
|
|
30
38
|
export { createJobRegistry, defaultParallelJobs } from "./regenerate/jobs.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting a project from the conversation: the person says what they want built - "create a small server in Java
|
|
3
|
+
* Spring Boot" - and the chat offers the three ways to start it: brood's interview, spawn's quick start, or writing the
|
|
4
|
+
* ***plain itself, here, with no skill. An option picked that starts with a skill's command starts that skill, as if
|
|
5
|
+
* typed (`chosenCommand` in skills.ts).
|
|
6
|
+
*/
|
|
7
|
+
export declare const NEW_PROJECT: string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting a project from the conversation: the person says what they want built - "create a small server in Java
|
|
3
|
+
* Spring Boot" - and the chat offers the three ways to start it: brood's interview, spawn's quick start, or writing the
|
|
4
|
+
* ***plain itself, here, with no skill. An option picked that starts with a skill's command starts that skill, as if
|
|
5
|
+
* typed (`chosenCommand` in skills.ts).
|
|
6
|
+
*/
|
|
7
|
+
export const NEW_PROJECT = [
|
|
8
|
+
"<new-project>",
|
|
9
|
+
"When the person asks for new software / project ",
|
|
10
|
+
"and there is no ***plain module for it yet, or they ask for a new project, ask them first how to start it, with",
|
|
11
|
+
"ask_user: one question, these three options, word for word, so they know the skills are there:",
|
|
12
|
+
`- "${"/brood - an interview: the stack, the testing and every feature settled with you, each answer regenerated"}"`,
|
|
13
|
+
`- "${"/spawn - something running in minutes: the quick start, asking only what your message leaves open"}"`,
|
|
14
|
+
`- "${"Just do it - no skill: I write it here and start regenerating"}"`,
|
|
15
|
+
"Picked /brood or /spawn: the answer starts it - call cultivate for it at once, with what they said as its context,",
|
|
16
|
+
"never asking them to type its command. Do not ask when their",
|
|
17
|
+
"message says already - they typed the command, or said to just build it, with no skill, without questions: then",
|
|
18
|
+
"start it yourself. Started yourself, how soon the first pulse starts, and something they can try, come first:",
|
|
19
|
+
"1. The stack: the one they named; otherwise one of the standard library's that this machine can build - common,",
|
|
20
|
+
" simple - said in a line. A stack named in a language the machine lacks is written all the same: tell them what to",
|
|
21
|
+
" install, and that turri doctor checks it. Read the stack's template, its language's and its kind's common first.",
|
|
22
|
+
"2. One module - a .plain file in the project's root, named after what it is (server.plain) - importing the stack and",
|
|
23
|
+
" writing only what the module needs beyond it. Two side by side, each on its stack, only when it needs two stacks.",
|
|
24
|
+
"3. config.turri.yaml: module: the new file, and conformance-tests: false unless they asked for conformance tests.",
|
|
25
|
+
" What a config there already settles - a stack, a script, conformance - is kept, not chosen again.",
|
|
26
|
+
"4. The unit testing script: keep one turri already finds; otherwise run cultivate with implement-unit-testing-script",
|
|
27
|
+
" for the stack's language. Conformance tests asked for: their ***test reqs*** - the stack's conformance template -",
|
|
28
|
+
" and cultivate with implement-conformance-testing-script.",
|
|
29
|
+
"5. The first functional spec: the smallest thing that runs and shows something - the server answers GET / with the",
|
|
30
|
+
" app's name, the CLI prints its usage. validate, fix what it finds, and regenerate at once.",
|
|
31
|
+
"6. While it regenerates: the main thing they described, on its happy path, and at most one more - each a functional",
|
|
32
|
+
" spec, with its concepts - then validate and regenerate again. Only what they described: they named only the kind of",
|
|
33
|
+
" software, write none, and invite them to say what it does. No validation or edge cases yet.",
|
|
34
|
+
"Then hand over in a few lines: what you chose, what is regenerating, and the next slices you would write - they say",
|
|
35
|
+
"go on, or what to change, and every change after is ***plain you write and regenerate.",
|
|
36
|
+
"</new-project>",
|
|
37
|
+
].join("\n");
|
package/dist/plain/concepts.d.ts
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
1
|
import { type AnyNode, type Section } from "./markdown.ts";
|
|
2
2
|
import { type MarshalledPlainSource, type SpecificationItem } from "./spec.ts";
|
|
3
3
|
/** Port of concept_utils.py. */
|
|
4
|
+
/**
|
|
5
|
+
* ***plain's own concepts, each with what it means, in a line: every module can name them, and none may define them.
|
|
6
|
+
* turri's, not the reference parser's - it has only the names.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_CONCEPT_DEFINITIONS: Readonly<Record<string, string>>;
|
|
4
9
|
/** Reserved concept names that must not be redefined in the definitions section. */
|
|
5
10
|
export declare const DEFAULT_CONCEPTS: Set<string>;
|
|
11
|
+
/** Every concept written in a piece of text - `:User:` - with where it is, as the parser reads concepts in a spec. */
|
|
12
|
+
export declare function findConcepts(text: string): Array<{
|
|
13
|
+
concept: string;
|
|
14
|
+
start: number;
|
|
15
|
+
end: number;
|
|
16
|
+
}>;
|
|
17
|
+
/** The concept being typed at the end of a piece of text - `:Us` - with where it starts, or undefined when there is none. */
|
|
18
|
+
export declare function conceptBeingTyped(text: string): {
|
|
19
|
+
typed: string;
|
|
20
|
+
start: number;
|
|
21
|
+
} | undefined;
|
|
22
|
+
/** A definition's text after the concepts it defines: `is a person who signs up` of `- :User: is a person who signs up`. */
|
|
23
|
+
export declare function definitionText(text: string): string;
|
|
6
24
|
export declare function extractConceptsFromDefinition(text: string): [string[], string[]];
|
|
7
25
|
export declare function extractConceptsFromSpecText(text: string): string[];
|
|
8
26
|
export declare function collectConcepts(plainSource: MarshalledPlainSource): [string[], string[]];
|
package/dist/plain/concepts.js
CHANGED
|
@@ -2,23 +2,50 @@ import { PlainSyntaxError } from "./errors.js";
|
|
|
2
2
|
import { renderToMarkdown } from "./markdown.js";
|
|
3
3
|
import { ACCEPTANCE_TESTS, DEFINITIONS, FUNCTIONAL_REQUIREMENTS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS, getSpecificationItems, } from "./spec.js";
|
|
4
4
|
/** Port of concept_utils.py. */
|
|
5
|
+
/**
|
|
6
|
+
* ***plain's own concepts, each with what it means, in a line: every module can name them, and none may define them.
|
|
7
|
+
* turri's, not the reference parser's - it has only the names.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_CONCEPT_DEFINITIONS = {
|
|
10
|
+
":ConformanceTests:": "the tests, written apart from the code, that check it conforms to the functional specs",
|
|
11
|
+
":UnitTests:": "the unit tests written with the code of each functional spec",
|
|
12
|
+
":AcceptanceTest:": "a test that checks one functional spec does what its acceptance tests say",
|
|
13
|
+
":AcceptanceTests:": "the tests that check a functional spec does what its acceptance tests say",
|
|
14
|
+
":Implementation:": "the software that implements the module's functional specs",
|
|
15
|
+
":plainDefinitions:": "the content of the module's ***definitions*** section",
|
|
16
|
+
":plainImplementationReqs:": "the content of the module's ***implementation reqs*** section",
|
|
17
|
+
":plainFunctionality:": "the content of the module's ***functional specs*** section",
|
|
18
|
+
":plainTestReqs:": "the content of the module's ***test reqs*** section",
|
|
19
|
+
":plainImplementationCode:": "the code generated from the module",
|
|
20
|
+
};
|
|
5
21
|
/** Reserved concept names that must not be redefined in the definitions section. */
|
|
6
|
-
export const DEFAULT_CONCEPTS = new Set(
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
":AcceptanceTest:",
|
|
10
|
-
":AcceptanceTests:",
|
|
11
|
-
":Implementation:",
|
|
12
|
-
":plainDefinitions:",
|
|
13
|
-
":plainImplementationReqs:",
|
|
14
|
-
":plainFunctionality:",
|
|
15
|
-
":plainTestReqs:",
|
|
16
|
-
":plainImplementationCode:",
|
|
17
|
-
]);
|
|
22
|
+
export const DEFAULT_CONCEPTS = new Set(Object.keys(DEFAULT_CONCEPT_DEFINITIONS));
|
|
23
|
+
/** What a concept's name is made of, between its two colons. */
|
|
24
|
+
const CONCEPT_CHARACTERS = "+\\-.0-9A-Z_a-z";
|
|
18
25
|
const DEFINITION_PATTERN = /-\s(:[^:]+:)(?:,\s*:[^:]+:)*/;
|
|
19
26
|
const CONCEPT_CANDIDATE_PATTERN = /:[^:]+:/g;
|
|
20
|
-
const VALID_CONCEPT_PATTERN =
|
|
21
|
-
const SPEC_CONCEPT_PATTERN =
|
|
27
|
+
const VALID_CONCEPT_PATTERN = new RegExp(`^:[${CONCEPT_CHARACTERS}]+:`);
|
|
28
|
+
const SPEC_CONCEPT_PATTERN = new RegExp(`:[${CONCEPT_CHARACTERS}]+:`, "g");
|
|
29
|
+
const TYPED_CONCEPT_PATTERN = new RegExp(`:[${CONCEPT_CHARACTERS}]*$`);
|
|
30
|
+
/** Every concept written in a piece of text - `:User:` - with where it is, as the parser reads concepts in a spec. */
|
|
31
|
+
export function findConcepts(text) {
|
|
32
|
+
return [...text.matchAll(SPEC_CONCEPT_PATTERN)].map((match) => ({ concept: match[0], start: match.index, end: match.index + match[0].length }));
|
|
33
|
+
}
|
|
34
|
+
/** The concept being typed at the end of a piece of text - `:Us` - with where it starts, or undefined when there is none. */
|
|
35
|
+
export function conceptBeingTyped(text) {
|
|
36
|
+
const match = TYPED_CONCEPT_PATTERN.exec(text);
|
|
37
|
+
if (match === null)
|
|
38
|
+
return undefined;
|
|
39
|
+
// A colon that closes a concept is not one that opens the next.
|
|
40
|
+
if (findConcepts(text).some(({ end }) => end - 1 === match.index))
|
|
41
|
+
return undefined;
|
|
42
|
+
return { typed: match[0], start: match.index };
|
|
43
|
+
}
|
|
44
|
+
/** A definition's text after the concepts it defines: `is a person who signs up` of `- :User: is a person who signs up`. */
|
|
45
|
+
export function definitionText(text) {
|
|
46
|
+
const match = DEFINITION_PATTERN.exec(text);
|
|
47
|
+
return (match === null ? text : text.slice(match.index + match[0].length)).trim();
|
|
48
|
+
}
|
|
22
49
|
export function extractConceptsFromDefinition(text) {
|
|
23
50
|
const match = DEFINITION_PATTERN.exec(text);
|
|
24
51
|
if (!match) {
|
package/dist/plain/files.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Ports of the Python reference implementation's file helpers this parser needs. */
|
|
2
2
|
export declare function openFrom(dirs: string[], fileName: string): string | null;
|
|
3
|
+
/** The file `openFrom` reads: the first of the folders that has it. */
|
|
4
|
+
export declare function findIn(dirs: string[], fileName: string): string | null;
|
|
3
5
|
/** First contiguous base64 blob at or above the threshold, or null. */
|
|
4
6
|
export declare function findLargeBase64Blob(text: string): string | null;
|
|
5
7
|
/**
|
package/dist/plain/files.js
CHANGED
|
@@ -3,16 +3,20 @@ import { join } from "node:path";
|
|
|
3
3
|
import { createEnvironment } from "./liquid.js";
|
|
4
4
|
/** Ports of the Python reference implementation's file helpers this parser needs. */
|
|
5
5
|
export function openFrom(dirs, fileName) {
|
|
6
|
+
const path = findIn(dirs, fileName);
|
|
7
|
+
return path === null ? null : readFileSync(path, "utf8");
|
|
8
|
+
}
|
|
9
|
+
/** The file `openFrom` reads: the first of the folders that has it. */
|
|
10
|
+
export function findIn(dirs, fileName) {
|
|
6
11
|
for (const dir of dirs) {
|
|
7
12
|
const fullPath = join(dir, fileName);
|
|
8
13
|
try {
|
|
9
|
-
if (
|
|
10
|
-
|
|
14
|
+
if (statSync(fullPath).isFile())
|
|
15
|
+
return fullPath;
|
|
11
16
|
}
|
|
12
17
|
catch {
|
|
13
18
|
continue;
|
|
14
19
|
}
|
|
15
|
-
return readFileSync(fullPath, "utf8");
|
|
16
20
|
}
|
|
17
21
|
return null;
|
|
18
22
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { DEFINITIONS, FUNCTIONAL_REQUIREMENTS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS } from "./spec.ts";
|
|
2
|
+
/**
|
|
3
|
+
* A project's ***plain as a graph, to go from one place in it to another: its modules, each item of their sections,
|
|
4
|
+
* and the concepts - an item linked to its module, to the concepts it names and to where each is defined, a module to
|
|
5
|
+
* what it imports and requires and to what imports and requires it.
|
|
6
|
+
*/
|
|
7
|
+
export interface PlainGraph {
|
|
8
|
+
modules: PlainModuleNode[];
|
|
9
|
+
items: PlainItemNode[];
|
|
10
|
+
concepts: PlainConceptNode[];
|
|
11
|
+
}
|
|
12
|
+
/** A module one .plain file names, and the file it is - null when it is not found. */
|
|
13
|
+
export interface PlainLink {
|
|
14
|
+
name: string;
|
|
15
|
+
file: string | null;
|
|
16
|
+
}
|
|
17
|
+
/** A .plain file: what it imports and requires, what imports and requires it, its items, and why it does not parse. */
|
|
18
|
+
export interface PlainModuleNode {
|
|
19
|
+
file: string;
|
|
20
|
+
module: string;
|
|
21
|
+
/** Resolved as the parser reads an import: a standard library module from turri's `stdlib/`. */
|
|
22
|
+
imports: PlainLink[];
|
|
23
|
+
/** Resolved as the requires chain finds a required module. */
|
|
24
|
+
requires: PlainLink[];
|
|
25
|
+
importedBy: PlainLink[];
|
|
26
|
+
requiredBy: PlainLink[];
|
|
27
|
+
/** Its own items, in the order they are written: their ids. */
|
|
28
|
+
items: string[];
|
|
29
|
+
/** The parser's message, when the file does not parse. Its links are then what its front matter says, its items what of its own text reads. */
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
export type PlainSection = typeof DEFINITIONS | typeof NON_FUNCTIONAL_REQUIREMENTS | typeof TEST_REQUIREMENTS | typeof FUNCTIONAL_REQUIREMENTS;
|
|
33
|
+
/** An item of a section, with what is nested under it - a functional spec with its acceptance tests. */
|
|
34
|
+
export interface PlainItemNode {
|
|
35
|
+
/** `<file>:<line>`. */
|
|
36
|
+
id: string;
|
|
37
|
+
file: string;
|
|
38
|
+
module: string;
|
|
39
|
+
section: PlainSection;
|
|
40
|
+
/** The line it starts on, from 1. */
|
|
41
|
+
line: number;
|
|
42
|
+
text: string;
|
|
43
|
+
/** A functional spec's functionality id: `1`, `2`, ... */
|
|
44
|
+
frid?: string;
|
|
45
|
+
/** The concepts a definition defines. */
|
|
46
|
+
defines: string[];
|
|
47
|
+
/** The concepts it names, each with the definition it is - an item's id - or null for ***plain's own and one defined nowhere it can see. */
|
|
48
|
+
uses: Array<{
|
|
49
|
+
concept: string;
|
|
50
|
+
definition: string | null;
|
|
51
|
+
}>;
|
|
52
|
+
}
|
|
53
|
+
/** A concept as defined in one place - or ***plain's own - and every item that names it there. */
|
|
54
|
+
export interface PlainConceptNode {
|
|
55
|
+
concept: string;
|
|
56
|
+
/** The item that defines it; null for ***plain's own. */
|
|
57
|
+
definition: string | null;
|
|
58
|
+
module: string | null;
|
|
59
|
+
/** The definition's text after its concepts. */
|
|
60
|
+
text: string;
|
|
61
|
+
usedBy: string[];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A project's ***plain as a graph: every .plain file in its folder and its `template/` folder, and every module they
|
|
65
|
+
* import or require, wherever the parser finds it, the standard library's too. Never throws: a file that does not
|
|
66
|
+
* parse is given with what its front matter says, what of its own text reads, and the error.
|
|
67
|
+
*/
|
|
68
|
+
export declare function plainGraph(projectDir: string): PlainGraph;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { resolveTemplateDirs } from "../regenerate/paths.js";
|
|
4
|
+
import { locateModule } from "../regenerate/requires.js";
|
|
5
|
+
import { DEFAULT_CONCEPT_DEFINITIONS, DEFAULT_CONCEPTS, definitionText, extractConceptsFromDefinition, findConcepts } from "./concepts.js";
|
|
6
|
+
import { lineNumber, renderToMarkdown } from "./markdown.js";
|
|
7
|
+
import { attempt, namesIn, readModule } from "./module-concepts.js";
|
|
8
|
+
import { IMPORT_DIRECTIVE, modulePlainFile, parsePlainFile, PLAIN_SOURCE_FILE_EXTENSION, plainFileParser, readPlainSourceMetadata, REQUIRES_DIRECTIVE, withParseCache, } from "./parser.js";
|
|
9
|
+
import { DEFINITIONS, FUNCTIONAL_REQUIREMENTS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS } from "./spec.js";
|
|
10
|
+
import { isStdlibModule } from "./stdlib.js";
|
|
11
|
+
const SECTIONS = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS, FUNCTIONAL_REQUIREMENTS];
|
|
12
|
+
/**
|
|
13
|
+
* A project's ***plain as a graph: every .plain file in its folder and its `template/` folder, and every module they
|
|
14
|
+
* import or require, wherever the parser finds it, the standard library's too. Never throws: a file that does not
|
|
15
|
+
* parse is given with what its front matter says, what of its own text reads, and the error.
|
|
16
|
+
*/
|
|
17
|
+
export function plainGraph(projectDir) {
|
|
18
|
+
const root = resolve(projectDir);
|
|
19
|
+
// The folders a .plain file of the project is read with: its own, its template/ folder, PLAIN_TEMPLATE_DIRS.
|
|
20
|
+
const dirs = resolveTemplateDirs(join(root, `project${PLAIN_SOURCE_FILE_EXTENSION}`));
|
|
21
|
+
const modules = new Map();
|
|
22
|
+
const queue = [];
|
|
23
|
+
const visit = (file, module) => {
|
|
24
|
+
if (modules.has(file))
|
|
25
|
+
return;
|
|
26
|
+
const node = { file, module, imports: [], requires: [], importedBy: [], requiredBy: [], items: [] };
|
|
27
|
+
modules.set(file, node);
|
|
28
|
+
queue.push(node);
|
|
29
|
+
};
|
|
30
|
+
for (const dir of [root, join(root, "template")]) {
|
|
31
|
+
const names = attempt(() => readdirSync(dir).filter((name) => name.endsWith(PLAIN_SOURCE_FILE_EXTENSION)).sort()) ?? [];
|
|
32
|
+
for (const name of names)
|
|
33
|
+
visit(join(dir, name), basename(name, PLAIN_SOURCE_FILE_EXTENSION));
|
|
34
|
+
}
|
|
35
|
+
for (let node = queue.shift(); node !== undefined; node = queue.shift()) {
|
|
36
|
+
let metadata = {};
|
|
37
|
+
try {
|
|
38
|
+
metadata = readPlainSourceMetadata(readFileSync(node.file, "utf8")).metadata;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
node.error = messageOf(error);
|
|
42
|
+
}
|
|
43
|
+
node.imports = namesIn(metadata[IMPORT_DIRECTIVE]).map((name) => ({ name, file: modulePlainFile(name, dirs) }));
|
|
44
|
+
node.requires = namesIn(metadata[REQUIRES_DIRECTIVE]).map((name) => ({ name, file: attempt(() => locateModule(name, dirs)) ?? null }));
|
|
45
|
+
for (const link of [...node.imports, ...node.requires])
|
|
46
|
+
if (link.file !== null)
|
|
47
|
+
visit(link.file, link.name);
|
|
48
|
+
}
|
|
49
|
+
for (const node of modules.values()) {
|
|
50
|
+
for (const link of node.imports)
|
|
51
|
+
if (link.file !== null)
|
|
52
|
+
modules.get(link.file).importedBy.push({ name: node.module, file: node.file });
|
|
53
|
+
for (const link of node.requires)
|
|
54
|
+
if (link.file !== null)
|
|
55
|
+
modules.get(link.file).requiredBy.push({ name: node.module, file: node.file });
|
|
56
|
+
}
|
|
57
|
+
return withParseCache(() => {
|
|
58
|
+
const items = [];
|
|
59
|
+
const concepts = new Map();
|
|
60
|
+
for (const [concept, text] of Object.entries(DEFAULT_CONCEPT_DEFINITIONS))
|
|
61
|
+
concepts.set(`${concept}\0`, { concept, definition: null, module: null, text, usedBy: [] });
|
|
62
|
+
const lines = new Map();
|
|
63
|
+
const idOf = (node, file) => `${file}:${lineOf(node, file, lines)}`;
|
|
64
|
+
const fileOf = (module, node) => (module === node.module ? node.file : modulePlainFile(module, dirs));
|
|
65
|
+
for (const node of modules.values()) {
|
|
66
|
+
if (node.error === undefined)
|
|
67
|
+
node.error = parseError(node, dirs);
|
|
68
|
+
const read = readModule(node.module, node.file, dirs);
|
|
69
|
+
// Where each concept it can name is defined, the first definition winning, as the parser's checks find one.
|
|
70
|
+
const visible = new Map();
|
|
71
|
+
for (const { node: definition, module } of read.definitions) {
|
|
72
|
+
const file = fileOf(module, node);
|
|
73
|
+
if (file === null)
|
|
74
|
+
continue;
|
|
75
|
+
const id = idOf(definition, file);
|
|
76
|
+
const text = renderToMarkdown(definition);
|
|
77
|
+
for (const concept of extractConceptsFromDefinition(text)[0]) {
|
|
78
|
+
if (!visible.has(concept))
|
|
79
|
+
visible.set(concept, id);
|
|
80
|
+
if (!concepts.has(`${concept}\0${id}`))
|
|
81
|
+
concepts.set(`${concept}\0${id}`, { concept, definition: id, module, text: definitionText(text), usedBy: [] });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const section of SECTIONS) {
|
|
85
|
+
const own = (read.parsed?.plainSource[section]?.children ?? []).filter((item) => !read.parsed.importedFrom.has(item));
|
|
86
|
+
own.forEach((item, index) => {
|
|
87
|
+
const text = renderToMarkdown(item).trim();
|
|
88
|
+
const defines = section === DEFINITIONS ? extractConceptsFromDefinition(text)[0] : [];
|
|
89
|
+
const named = [...new Set(findConcepts(text).map((found) => found.concept))].filter((concept) => !defines.includes(concept));
|
|
90
|
+
const id = idOf(item, node.file);
|
|
91
|
+
const uses = named.map((concept) => ({ concept, definition: DEFAULT_CONCEPTS.has(concept) ? null : (visible.get(concept) ?? null) }));
|
|
92
|
+
for (const use of uses)
|
|
93
|
+
concepts.get(`${use.concept}\0${use.definition ?? ""}`)?.usedBy.push(id);
|
|
94
|
+
items.push({ id, file: node.file, module: node.module, section, line: lineOf(item, node.file, lines), text, ...(section === FUNCTIONAL_REQUIREMENTS ? { frid: String(index + 1) } : {}), defines, uses });
|
|
95
|
+
node.items.push(id);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { modules: [...modules.values()], items, concepts: [...concepts.values()] };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** Why a module does not parse: an import module as an import is parsed, a module of its own as one - functional specs or not yet. */
|
|
103
|
+
function parseError(node, dirs) {
|
|
104
|
+
const imported = node.importedBy.length > 0 || isStdlibModule(node.module) || basename(dirname(node.file)) === "template";
|
|
105
|
+
try {
|
|
106
|
+
if (imported)
|
|
107
|
+
parsePlainFile(node.module, {}, dirs, [], []);
|
|
108
|
+
else
|
|
109
|
+
plainFileParser(`${node.module}${PLAIN_SOURCE_FILE_EXTENSION}`, dirs, { allowNoFunctionalities: true });
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return messageOf(error);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The line an item starts on in its file. The parser's is a line of the text after Liquid has run, which is the file's
|
|
118
|
+
* own unless an include or a tag changed the lines above it: then the first line of the file that has the item's
|
|
119
|
+
* first line is taken.
|
|
120
|
+
*/
|
|
121
|
+
function lineOf(node, file, cache) {
|
|
122
|
+
let lines = cache.get(file);
|
|
123
|
+
if (lines === undefined) {
|
|
124
|
+
lines = (attempt(() => readFileSync(file, "utf8")) ?? "").split(/\r?\n/);
|
|
125
|
+
cache.set(file, lines);
|
|
126
|
+
}
|
|
127
|
+
const at = lineNumber(node);
|
|
128
|
+
const first = renderToMarkdown(node).trim().split("\n")[0].replace(/^[-*+]\s+/, "").trim();
|
|
129
|
+
if (first === "" || lines[at - 1]?.includes(first))
|
|
130
|
+
return at;
|
|
131
|
+
const found = lines.findIndex((line) => line.includes(first));
|
|
132
|
+
return found === -1 ? at : found + 1;
|
|
133
|
+
}
|
|
134
|
+
function messageOf(error) {
|
|
135
|
+
return error instanceof Error ? error.message : String(error);
|
|
136
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type AnyNode } from "./markdown.ts";
|
|
2
|
+
import { type PlainFileParseResult } from "./parser.ts";
|
|
3
|
+
/** A concept a module can name, what it is, and the module that defines it - null for ***plain's own. */
|
|
4
|
+
export interface ModuleConcept {
|
|
5
|
+
concept: string;
|
|
6
|
+
/** The definition's text after its concepts: `is a person who signs up`. */
|
|
7
|
+
definition: string;
|
|
8
|
+
module: string | null;
|
|
9
|
+
}
|
|
10
|
+
/** A module as the parser reads it, for what it names: its own parse, and every definition it can name. */
|
|
11
|
+
export interface ReadModule {
|
|
12
|
+
/** Its parse, its imports merged in - or, when that fails, its own text read without them; undefined when neither reads. */
|
|
13
|
+
parsed?: PlainFileParseResult;
|
|
14
|
+
/** Its own definitions, those its imports bring and those its required modules export, each with the module it is written in. */
|
|
15
|
+
definitions: Array<{
|
|
16
|
+
node: AnyNode;
|
|
17
|
+
module: string;
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A module read for what it can name, as the parser builds its definitions: its own, its imports' (`import`), and its
|
|
22
|
+
* required modules' `exported_concepts` with the definitions they use. Never throws: a module that does not parse
|
|
23
|
+
* gives its own definitions read without its imports, and what of those it names is there.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readModule(moduleName: string, plainFile: string, templateDirs: string[]): ReadModule;
|
|
26
|
+
/**
|
|
27
|
+
* Every concept a module can name: its own definitions, those its imports bring, those its required modules export,
|
|
28
|
+
* and ***plain's own - read as the parser reads them. A module that does not parse gives what can be read of it.
|
|
29
|
+
*/
|
|
30
|
+
export declare function conceptsOf(plainFile: string, templateDirs?: string[]): ModuleConcept[];
|
|
31
|
+
export declare function attempt<T>(run: () => T): T | undefined;
|
|
32
|
+
export declare function namesIn(value: unknown): string[];
|