@turri-dev/cli 0.1.27 → 0.2.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 (216) hide show
  1. package/README.md +6 -6
  2. package/dist/advisor-chat.d.ts +63 -0
  3. package/dist/advisor-chat.js +290 -0
  4. package/dist/background-skills.d.ts +53 -0
  5. package/dist/background-skills.js +121 -0
  6. package/dist/bedrock-region.d.ts +66 -0
  7. package/dist/bedrock-region.js +144 -0
  8. package/dist/btw.d.ts +1 -1
  9. package/dist/btw.js +1 -1
  10. package/dist/change-plain-cli.js +20 -13
  11. package/dist/chat-idle.d.ts +7 -0
  12. package/dist/chat-idle.js +20 -0
  13. package/dist/chats.d.ts +10 -0
  14. package/dist/chats.js +23 -0
  15. package/dist/cli-options.d.ts +1 -2
  16. package/dist/cli-options.js +2 -7
  17. package/dist/cli.js +11 -3
  18. package/dist/config-command.d.ts +14 -0
  19. package/dist/config-command.js +35 -15
  20. package/dist/config-edit.d.ts +3 -3
  21. package/dist/config-edit.js +16 -12
  22. package/dist/config.d.ts +54 -34
  23. package/dist/config.js +40 -44
  24. package/dist/conform-cli.js +12 -10
  25. package/dist/conformance-switch.d.ts +34 -0
  26. package/dist/conformance-switch.js +108 -0
  27. package/dist/cultivate-cli.js +15 -5
  28. package/dist/drift-cli.js +2 -0
  29. package/dist/extension.d.ts +15 -0
  30. package/dist/extension.js +260 -83
  31. package/dist/host-session.d.ts +26 -0
  32. package/dist/host-session.js +30 -2
  33. package/dist/host.d.ts +5 -2
  34. package/dist/host.js +7 -1
  35. package/dist/init-config.d.ts +1 -1
  36. package/dist/init-config.js +20 -11
  37. package/dist/loop-cli.d.ts +3 -3
  38. package/dist/loop-cli.js +13 -16
  39. package/dist/loop-tools.js +18 -10
  40. package/dist/model-check.d.ts +8 -3
  41. package/dist/model-check.js +13 -8
  42. package/dist/new-project.js +12 -8
  43. package/dist/{validate-cli.js → parse-cli.js} +11 -5
  44. package/dist/parse-tool.d.ts +9 -0
  45. package/dist/parse-tool.js +33 -0
  46. package/dist/pi-launch.js +1 -1
  47. package/dist/plain/check.d.ts +53 -6
  48. package/dist/plain/check.js +217 -24
  49. package/dist/plain/concepts.d.ts +8 -0
  50. package/dist/plain/concepts.js +66 -23
  51. package/dist/plain/cycles.d.ts +21 -0
  52. package/dist/plain/cycles.js +190 -0
  53. package/dist/plain/errors.d.ts +4 -0
  54. package/dist/plain/errors.js +7 -0
  55. package/dist/plain/files.d.ts +18 -1
  56. package/dist/plain/files.js +39 -2
  57. package/dist/plain/graph.d.ts +33 -4
  58. package/dist/plain/graph.js +60 -12
  59. package/dist/plain/highlight.d.ts +3 -2
  60. package/dist/plain/highlight.js +4 -3
  61. package/dist/plain/index.d.ts +8 -5
  62. package/dist/plain/index.js +8 -5
  63. package/dist/plain/liquid.js +1 -6
  64. package/dist/plain/markdown.d.ts +32 -3
  65. package/dist/plain/markdown.js +146 -24
  66. package/dist/plain/module-concepts.js +2 -2
  67. package/dist/plain/parser.d.ts +83 -28
  68. package/dist/plain/parser.js +381 -260
  69. package/dist/plain/plain.tmLanguage.json +29 -3
  70. package/dist/plain/project.d.ts +48 -0
  71. package/dist/plain/project.js +155 -0
  72. package/dist/plain/spec.d.ts +90 -28
  73. package/dist/plain/spec.js +107 -133
  74. package/dist/plain/test-plain-file.d.ts +59 -0
  75. package/dist/plain/test-plain-file.js +232 -0
  76. package/dist/regenerate/activity.d.ts +20 -9
  77. package/dist/regenerate/activity.js +11 -4
  78. package/dist/regenerate/advisor-records.d.ts +93 -0
  79. package/dist/regenerate/advisor-records.js +110 -0
  80. package/dist/regenerate/advisor-runs.d.ts +87 -0
  81. package/dist/regenerate/advisor-runs.js +327 -0
  82. package/dist/regenerate/base-folder.d.ts +68 -0
  83. package/dist/regenerate/base-folder.js +168 -0
  84. package/dist/regenerate/cases.d.ts +9 -6
  85. package/dist/regenerate/cases.js +70 -14
  86. package/dist/regenerate/change-view.d.ts +8 -3
  87. package/dist/regenerate/change-view.js +71 -22
  88. package/dist/regenerate/chunk-plan.d.ts +36 -15
  89. package/dist/regenerate/chunk-plan.js +165 -59
  90. package/dist/regenerate/conform.d.ts +3 -0
  91. package/dist/regenerate/conform.js +6 -5
  92. package/dist/regenerate/conformance-agents.d.ts +18 -39
  93. package/dist/regenerate/conformance-agents.js +60 -88
  94. package/dist/regenerate/conformance.d.ts +23 -13
  95. package/dist/regenerate/conformance.js +119 -82
  96. package/dist/regenerate/diff-view.d.ts +1 -1
  97. package/dist/regenerate/diff-view.js +20 -4
  98. package/dist/regenerate/drift.d.ts +47 -15
  99. package/dist/regenerate/drift.js +101 -30
  100. package/dist/regenerate/jobs.d.ts +2 -2
  101. package/dist/regenerate/jobs.js +1 -1
  102. package/dist/regenerate/ledger.d.ts +19 -0
  103. package/dist/regenerate/loop.d.ts +62 -43
  104. package/dist/regenerate/loop.js +472 -341
  105. package/dist/regenerate/paths.d.ts +4 -0
  106. package/dist/regenerate/paths.js +7 -1
  107. package/dist/regenerate/plain-palette.js +2 -0
  108. package/dist/regenerate/prompt.d.ts +57 -19
  109. package/dist/regenerate/prompt.js +157 -122
  110. package/dist/regenerate/records.d.ts +43 -12
  111. package/dist/regenerate/records.js +102 -88
  112. package/dist/regenerate/regenerate.js +29 -11
  113. package/dist/regenerate/regression.d.ts +4 -4
  114. package/dist/regenerate/regression.js +14 -10
  115. package/dist/regenerate/renames.d.ts +42 -0
  116. package/dist/regenerate/renames.js +185 -0
  117. package/dist/regenerate/requires.d.ts +1 -1
  118. package/dist/regenerate/requires.js +10 -3
  119. package/dist/regenerate/resources.d.ts +5 -3
  120. package/dist/regenerate/resources.js +4 -4
  121. package/dist/regenerate/scripts-being-written.d.ts +19 -0
  122. package/dist/regenerate/scripts-being-written.js +30 -0
  123. package/dist/regenerate/spec-map-view.d.ts +2 -2
  124. package/dist/regenerate/spec-map-view.js +46 -15
  125. package/dist/regenerate/spec-map.d.ts +27 -5
  126. package/dist/regenerate/spec-map.js +92 -60
  127. package/dist/regenerate/spec-steps.d.ts +48 -5
  128. package/dist/regenerate/spec-steps.js +213 -28
  129. package/dist/regenerate/state.d.ts +4 -3
  130. package/dist/regenerate/stop-report.d.ts +1 -1
  131. package/dist/regenerate/stop-report.js +10 -10
  132. package/dist/regenerate/test-advisor.d.ts +101 -0
  133. package/dist/regenerate/test-advisor.js +379 -0
  134. package/dist/regenerate/test-kinds.d.ts +71 -28
  135. package/dist/regenerate/test-kinds.js +110 -63
  136. package/dist/regenerate/test-scripts.d.ts +2 -0
  137. package/dist/regenerate/test-scripts.js +4 -0
  138. package/dist/regenerate/testing-steps.d.ts +41 -0
  139. package/dist/regenerate/testing-steps.js +110 -0
  140. package/dist/regenerate/testing.d.ts +1 -1
  141. package/dist/regenerate/testing.js +5 -4
  142. package/dist/regenerate/unit-review.d.ts +1 -1
  143. package/dist/regenerate/unit-review.js +3 -3
  144. package/dist/regenerate-cli.js +11 -12
  145. package/dist/release-agent.js +4 -0
  146. package/dist/release-cli.js +2 -0
  147. package/dist/release-tools.js +2 -0
  148. package/dist/run-report.d.ts +2 -2
  149. package/dist/run-report.js +4 -3
  150. package/dist/self-invocation.d.ts +1 -1
  151. package/dist/self-invocation.js +1 -1
  152. package/dist/sentinel.js +1 -1
  153. package/dist/setup.d.ts +10 -0
  154. package/dist/setup.js +52 -4
  155. package/dist/skill.d.ts +10 -0
  156. package/dist/skill.js +6 -0
  157. package/dist/skills.js +3 -1
  158. package/dist/spec-tools.js +21 -31
  159. package/dist/test-plain-files.d.ts +41 -0
  160. package/dist/test-plain-files.js +177 -0
  161. package/docs/README.md +3 -1
  162. package/docs/authoring-plain.md +46 -13
  163. package/docs/chats.md +3 -1
  164. package/docs/config.md +20 -15
  165. package/docs/deploy.md +1 -1
  166. package/docs/development.md +2 -1
  167. package/docs/headless.md +15 -18
  168. package/docs/hosting.md +28 -12
  169. package/docs/installation.md +1 -0
  170. package/docs/parser.md +88 -27
  171. package/docs/regenerating.md +87 -52
  172. package/docs/sentinel.md +1 -1
  173. package/docs/skills-and-gates.md +3 -1
  174. package/docs/test-advisor.md +110 -0
  175. package/docs/theming.md +1 -1
  176. package/docs/using.md +17 -15
  177. package/genome/rules/bullet-continuation.md +8 -8
  178. package/genome/rules/concise-specs-examples.md +111 -80
  179. package/genome/rules/concise-specs-integration-examples.md +56 -41
  180. package/genome/rules/concise-specs.md +17 -16
  181. package/genome/rules/definitions.md +5 -5
  182. package/genome/rules/examples/integration-embedded-testing/run_conformance_tests_java.sh +1 -1
  183. package/genome/rules/func-specs.md +69 -74
  184. package/genome/rules/impl-reqs.md +4 -4
  185. package/genome/rules/integration-embedded-testing.md +20 -20
  186. package/genome/rules/integration-embedded.md +31 -31
  187. package/genome/rules/integration-standalone.md +9 -9
  188. package/genome/rules/integrations.md +18 -18
  189. package/genome/rules/linked-resources.md +19 -16
  190. package/genome/rules/module-structure.md +21 -14
  191. package/genome/rules/project-model.md +18 -17
  192. package/genome/rules/required-concepts.md +1 -1
  193. package/genome/rules/requires-modules.md +6 -5
  194. package/genome/rules/semantic-ids.md +49 -0
  195. package/genome/rules/stdlib.md +4 -5
  196. package/genome/rules/test-plain-files.md +130 -0
  197. package/genome/rules/test-reqs.md +8 -8
  198. package/genome/rules/turri-regeneration.md +23 -8
  199. package/genome/skills/analyze-2-func-specs/SKILL.md +36 -21
  200. package/genome/skills/analyze-func-specs/SKILL.md +46 -32
  201. package/genome/skills/break-down-func-spec/SKILL.md +91 -67
  202. package/genome/skills/consolidate-concepts/SKILL.md +10 -10
  203. package/genome/skills/convert-old-syntax/SKILL.md +68 -0
  204. package/genome/skills/convert-old-syntax/references/rubric.md +11 -0
  205. package/genome/skills/debug-specs/SKILL.md +62 -56
  206. package/genome/skills/implement-conformance-testing-script/SKILL.md +19 -15
  207. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_java.sh +2 -2
  208. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.sh +2 -2
  209. package/genome/skills/implement-unit-testing-script/SKILL.md +14 -10
  210. package/genome/skills/refactor-module/SKILL.md +35 -35
  211. package/genome/skills/resolve-section-ownership/SKILL.md +9 -9
  212. package/genome/skills/resolve-spec-conflict/SKILL.md +35 -29
  213. package/package.json +7 -6
  214. package/dist/regenerate/choose-conformance.d.ts +0 -32
  215. package/dist/regenerate/choose-conformance.js +0 -117
  216. /package/dist/{validate-cli.d.ts → parse-cli.d.ts} +0 -0
@@ -1,19 +1,29 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
- import { extname, isAbsolute, basename, resolve } from "node:path";
3
+ import { extname, isAbsolute, basename, join, resolve } from "node:path";
4
4
  import { load as loadYaml } from "js-yaml";
5
5
  import { extractConceptsFromDefinition, findConceptDefinitionsInPlainSource, sortDefinitions, validateConcepts, DEFAULT_CONCEPTS, } from "./concepts.js";
6
6
  import { ImportedModuleWithFunctionalitiesError, MissingFunctionalitiesError, ModuleDoesNotExistError, PlainSyntaxError, UnsupportedBase64Content, } from "./errors.js";
7
7
  import { findIn, findLargeBase64Blob, getLoadedTemplates, openFrom } from "./files.js";
8
8
  import { renderPlainSource } from "./liquid.js";
9
9
  import { isStdlibModule, readStdlibModule, stdlibModuleFile } from "./stdlib.js";
10
- import { getRawText, lineNumber, parseMarkdown, removeQuotes, renderToMarkdown, traverse, } from "./markdown.js";
11
- import { ACCEPTANCE_TESTS, ACCEPTANCE_TEST_HEADING, ALLOWED_IMPORT_SPECIFICATION_HEADINGS, ALLOWED_SPECIFICATION_HEADINGS, DEFINITIONS, FUNCTIONAL_REQUIREMENTS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS, getSpecificationItems, REQUIRED_FUNCTIONAL_SPECS, } from "./spec.js";
10
+ import { getRawText, lineNumber, parseMarkdown, removeQuotes, renderToMarkdown, renderTokens, traverse, } from "./markdown.js";
11
+ import { ACCEPTANCE_TESTS, ACCEPTANCE_TEST_HEADING, ALLOWED_IMPORT_SPECIFICATION_HEADINGS, ALLOWED_SPECIFICATION_HEADINGS, CONFORMANCE_TESTS, CONFORMANCE_TEST_HEADING, DEFINITIONS, FUNCTIONAL_REQUIREMENTS, LIST_SECTION_HEADINGS, NON_FUNCTIONAL_REQUIREMENTS, TEST_PLAIN_FILES, TEST_REQUIREMENTS, getFunctionalSpecs, getSpecificationItems, isSemanticId, REQUIRED_FUNCTIONAL_SPECS, } from "./spec.js";
12
+ import { bindTestPlainFiles, checkTestPlainFiles } from "./project.js";
12
13
  export const IMPORT_DIRECTIVE = "import";
13
14
  export const REQUIRES_DIRECTIVE = "requires";
15
+ export const PREVIOUS_SYNTAX_HINT = "A functionality is a `# <func-id>` heading followed by its body; the previous syntax is not supported.";
14
16
  export const REQUIRED_CONCEPTS_DIRECTIVE = "required_concepts";
15
17
  export const EXPORTED_CONCEPTS_DIRECTIVE = "exported_concepts";
16
18
  export const PLAIN_SOURCE_FILE_EXTENSION = ".plain";
19
+ /** A list section of a parsed module. */
20
+ export function sectionOf(plainSource, heading) {
21
+ return plainSource[heading];
22
+ }
23
+ /** The functional specs of a parsed module, none when it has no section for them. */
24
+ export function functionalitiesOf(plainSource) {
25
+ return plainSource[FUNCTIONAL_REQUIREMENTS] ?? [];
26
+ }
17
27
  function newPlainSource() {
18
28
  return {
19
29
  [DEFINITIONS]: null,
@@ -31,20 +41,19 @@ export function hasScheme(target) {
31
41
  return /^[A-Za-z][A-Za-z0-9+.-]*:/.test(target);
32
42
  }
33
43
  /**
34
- * A link's file: found where resources are loaded from - the spec's own folder first, then the
35
- * other template folders - or else from the current directory, as the reference parser has it.
36
- * Links are relative to the spec, so a spec in a subfolder is checked where it is, wherever
37
- * turri was started.
44
+ * Port of loaders.resolve_linked_resource: a link's file, in the first of the folders resources are loaded from - the
45
+ * spec's own folder first, then the other template folders - where it exists, or null. Existence, not being a file,
46
+ * decides the match, as in `open_from`'s search. Links are relative to the spec, so a spec in a subfolder is checked
47
+ * where it is, wherever turri was started.
38
48
  */
39
- function linkedFile(target, dirs) {
49
+ export function resolveLinkedResource(dirs, target) {
40
50
  for (const dir of dirs) {
41
- const candidate = resolve(dir, target);
42
- consulted?.add(candidate);
51
+ const candidate = join(dir, target);
52
+ consulted?.add(resolve(candidate));
43
53
  if (existsSync(candidate))
44
54
  return candidate;
45
55
  }
46
- consulted?.add(resolve(target));
47
- return target;
56
+ return null;
48
57
  }
49
58
  /**
50
59
  * The linked files a parse looked for, found or not, while one runs: a kept parse holds while each is as it was - a
@@ -92,55 +101,63 @@ function stillHolds(kept, fingerprint) {
92
101
  export function linksIn(section) {
93
102
  return [...traverse(section, "link")].map((link) => ({ text: String(link.children?.[0]?.value ?? ""), target: String(link.url ?? ""), node: link }));
94
103
  }
95
- export function checkSectionForLinkedResources(section, dirs = []) {
104
+ /**
105
+ * The links in the blocks, each checked to be a relative path to an existing file that is not a .plain file.
106
+ * `where` names the place of the links in error messages, e.g. " in test plain file 'tests/m/f.plain', scenario `s`".
107
+ */
108
+ export function findLinkedResources(nodes, dirs = [], where = "") {
96
109
  const linkedResources = [];
97
- for (const { text, target, node: link } of linksIn(section)) {
110
+ for (const { text, target, node: link } of nodes.flatMap((node) => linksIn(node))) {
98
111
  const children = link.children ?? [];
99
112
  if (hasScheme(target) || isAbsolute(target)) {
100
- throw new PlainSyntaxError(`Plain syntax error: Only relative links are allowed (text: ${text}, target: ${target}).`);
113
+ throw new PlainSyntaxError(`Plain syntax error: Only relative links are allowed${where} (text: ${text}, target: ${target}).`);
101
114
  }
102
- const file = linkedFile(target, dirs);
103
- if (!existsSync(file)) {
104
- throw new PlainSyntaxError(`Plain syntax error: Link ${target} does not exist.`);
115
+ const file = resolveLinkedResource(dirs, target);
116
+ if (file === null) {
117
+ const searched = dirs.map((dir, index) => ` ${index + 1}. ${dir}`).join("\n");
118
+ throw new PlainSyntaxError(`Plain syntax error: Link ${target}${where} does not exist. ` +
119
+ "Linked resources are looked up relative to the following directories " +
120
+ `(highest to lowest precedence):\n${searched}`);
105
121
  }
106
122
  if (!statSync(file).isFile()) {
107
- throw new PlainSyntaxError(`Plain syntax error: Link ${target} must be a file.`);
123
+ throw new PlainSyntaxError(`Plain syntax error: Link ${target}${where} must be a file (resolved to ${file}).`);
108
124
  }
109
125
  if (children.length !== 1) {
110
- throw new PlainSyntaxError(`Plain syntax error: Link must have text specified (link: ${target}).`);
126
+ throw new PlainSyntaxError(`Plain syntax error: Link${where} must have text specified (link: ${target}).`);
111
127
  }
112
128
  if (extname(basename(target)) === ".plain") {
113
- throw new PlainSyntaxError(`Referenced resource '${target}' is a .plain file. ` +
129
+ throw new PlainSyntaxError(`Referenced resource '${target}'${where} is a .plain file. ` +
114
130
  "Referencing .plain files through Linked Resources is not supported. " +
115
131
  "Please use the import section to include definitions or implementation/test requirements from another module.");
116
132
  }
117
133
  linkedResources.push({ text, target });
118
134
  }
135
+ return linkedResources;
136
+ }
137
+ export function checkSectionForLinkedResources(section, dirs = []) {
138
+ const linkedResources = findLinkedResources([section], dirs);
119
139
  if (linkedResources.length > 0)
120
140
  section.linked_resources = linkedResources;
121
141
  }
122
142
  export function checkForLinkedResources(plainSource, dirs = []) {
123
- for (const heading of ALLOWED_SPECIFICATION_HEADINGS) {
124
- const section = plainSource[heading];
125
- if (!section)
126
- continue;
127
- for (const requirement of section.children) {
143
+ for (const heading of LIST_SECTION_HEADINGS) {
144
+ for (const requirement of sectionOf(plainSource, heading)?.children ?? [])
128
145
  checkSectionForLinkedResources(requirement, dirs);
129
- for (const acceptanceTest of requirement[ACCEPTANCE_TESTS] ?? []) {
130
- checkSectionForLinkedResources(acceptanceTest, dirs);
131
- }
132
- }
146
+ }
147
+ for (const functionality of functionalitiesOf(plainSource)) {
148
+ functionality.linkedResources = findLinkedResources(functionality.body, dirs);
133
149
  }
134
150
  }
135
- export function processSectionCodeVariables(section, codeVariables) {
136
- if (!("markdown" in section))
151
+ /** Put `{{ name }}` placeholders back into the section text and record the code variables it uses. */
152
+ export function processSectionCodeVariables(section, codeVariables, textKey = "markdown") {
153
+ if (!(textKey in section))
137
154
  return;
138
155
  const codeVariablesArray = [];
139
156
  for (const [key, value] of Object.entries(codeVariables)) {
140
- if (!section.markdown.includes(key))
157
+ if (!String(section[textKey]).includes(key))
141
158
  continue;
142
159
  const variableName = Object.keys(value)[0];
143
- section.markdown = section.markdown.split(key).join(`{{ ${variableName} }}`);
160
+ section[textKey] = String(section[textKey]).split(key).join(`{{ ${variableName} }}`);
144
161
  codeVariablesArray.push({ name: variableName, value: value[variableName] });
145
162
  }
146
163
  if (codeVariablesArray.length > 0)
@@ -159,11 +176,10 @@ export function hasFunctionalSpecsSection(plainSource) {
159
176
  }
160
177
  /** Number of functionalities under ***functional specs*** (0 if absent or empty). */
161
178
  export function countFunctionalities(plainSource) {
162
- const section = plainSource[FUNCTIONAL_REQUIREMENTS];
163
- return section ? section.children.length : 0;
179
+ return functionalitiesOf(plainSource).length;
164
180
  }
165
181
  export function validateFunctionalitiesHaveImplementationReqs(plainSource, moduleName, hasRequires = false) {
166
- const implementationReqs = plainSource[NON_FUNCTIONAL_REQUIREMENTS];
182
+ const implementationReqs = sectionOf(plainSource, NON_FUNCTIONAL_REQUIREMENTS);
167
183
  const hasImplementationReqs = Boolean(implementationReqs && implementationReqs.children.length > 0);
168
184
  if (hasImplementationReqs)
169
185
  return;
@@ -176,113 +192,19 @@ export function validateFunctionalitiesHaveImplementationReqs(plainSource, modul
176
192
  }
177
193
  throw new PlainSyntaxError(message);
178
194
  }
179
- /**
180
- * Is this token an `acceptance tests` heading?
181
- *
182
- * True only for a paragraph holding exactly one emphasis, holding exactly one strong,
183
- * holding exactly one text node whose content is the heading. Anything that looks like
184
- * a heading but reads differently comes back as a problem string rather than false, so
185
- * the caller can reject a near miss instead of silently keeping it.
186
- */
187
- export function isAcceptanceTestHeading(token) {
188
- if (token.type !== "paragraph")
189
- return [false, null];
190
- const children = token.children ?? [];
191
- if (children.length !== 1 || children[0].type !== "emphasis")
192
- return [false, null];
193
- const emphasisChildren = children[0].children ?? [];
194
- if (emphasisChildren.length !== 1 || emphasisChildren[0].type !== "strong")
195
- return [false, null];
196
- const strongChildren = emphasisChildren[0].children ?? [];
197
- if (strongChildren.length !== 1 || strongChildren[0].type !== "text")
198
- return [false, null];
199
- const content = String(strongChildren[0].value ?? "").trim();
200
- if (content === ACCEPTANCE_TEST_HEADING)
201
- return [true, null];
202
- return [
203
- false,
204
- `Syntax error at line ${lineNumber(token)}: Invalid acceptance test heading (\`${content}\`). ` +
205
- `Expected: \`${ACCEPTANCE_TEST_HEADING}.\``,
206
- ];
207
- }
208
- /** Find an acceptance tests heading anywhere in a subtree. */
209
- export function findAcceptanceTestHeading(token) {
210
- const [isHeading] = isAcceptanceTestHeading(token);
211
- if (isHeading)
212
- return token;
213
- for (const child of token.children ?? []) {
214
- const found = findAcceptanceTestHeading(child);
215
- if (found !== null)
216
- return found;
217
- }
218
- return null;
219
- }
220
- /**
221
- * Pull the acceptance tests out of one functionality.
222
- *
223
- * A functionality with acceptance tests has three children: its own text, the
224
- * `***acceptance tests***` heading, and the list of tests. The heading and list are
225
- * lifted onto `acceptance_tests` and removed from the children.
226
- */
227
- export function processSingleAcceptanceTestRequirement(functionalRequirement) {
228
- const newChildren = [];
229
- const children = functionalRequirement.children ?? [];
230
- let acceptanceTestsFoundAlready = false;
231
- for (let index = 0; index < children.length; index += 1) {
232
- const child = children[index];
233
- const [isHeading, problem] = isAcceptanceTestHeading(child);
234
- if (problem) {
235
- // A near-miss heading: `acceptance test` instead of `acceptance tests`, or some
236
- // other block written where the tests were meant to go.
237
- throw new PlainSyntaxError(`Plain syntax error: ${problem}`);
238
- }
239
- if (!isHeading) {
240
- newChildren.push(child);
241
- continue;
242
- }
243
- if (acceptanceTestsFoundAlready) {
244
- throw new PlainSyntaxError(`Plain syntax error: Syntax error at line ${lineNumber(child)}: Duplicate 'acceptance tests' ` +
245
- "heading found within the same functionality. Only one block of acceptance tests is allowed per functionality.");
246
- }
247
- const nextToken = children[index + 1];
248
- if (nextToken === undefined) {
249
- newChildren.push(child);
250
- continue;
251
- }
252
- index += 1;
253
- if (nextToken.type === "list") {
254
- if (functionalRequirement[ACCEPTANCE_TESTS] === undefined) {
255
- functionalRequirement[ACCEPTANCE_TESTS] = [];
256
- }
257
- for (const listItem of nextToken.children ?? []) {
258
- functionalRequirement[ACCEPTANCE_TESTS].push(listItem);
259
- }
260
- acceptanceTestsFoundAlready = true;
261
- }
262
- else {
263
- // Not followed by a list: keep both tokens.
264
- newChildren.push(child);
265
- newChildren.push(nextToken);
266
- }
267
- }
268
- functionalRequirement.children = newChildren;
269
- }
270
- export function processAcceptanceTests(plainSource) {
271
- if (!(FUNCTIONAL_REQUIREMENTS in plainSource))
272
- return;
273
- const frs = plainSource[FUNCTIONAL_REQUIREMENTS];
274
- if (!frs)
275
- return;
276
- for (const functionalRequirement of frs.children) {
277
- if (functionalRequirement.children === undefined)
278
- continue;
279
- processSingleAcceptanceTestRequirement(functionalRequirement);
280
- }
195
+ export function marshallFunctionality(functionality) {
196
+ return {
197
+ id: functionality.id,
198
+ markdown: renderTokens(functionality.body),
199
+ linked_resources: functionality.linkedResources,
200
+ [CONFORMANCE_TESTS]: [],
201
+ [ACCEPTANCE_TESTS]: [],
202
+ };
281
203
  }
282
204
  export function marshallPlainSource(input) {
283
205
  const plainSource = {};
284
- for (const heading of ALLOWED_SPECIFICATION_HEADINGS) {
285
- const section = input[heading];
206
+ for (const heading of LIST_SECTION_HEADINGS) {
207
+ const section = sectionOf(input, heading);
286
208
  if (!section)
287
209
  continue;
288
210
  const listOfRequirements = [];
@@ -293,19 +215,13 @@ export function marshallPlainSource(input) {
293
215
  if (requirement.linked_resources) {
294
216
  requirementSection.linked_resources = requirement.linked_resources;
295
217
  }
296
- const acceptanceTests = requirement[ACCEPTANCE_TESTS];
297
- if (acceptanceTests !== undefined) {
298
- requirementSection[ACCEPTANCE_TESTS] = acceptanceTests.map((acceptanceTest) => {
299
- const section = { markdown: renderToMarkdown(acceptanceTest).trim() };
300
- if (acceptanceTest.linked_resources)
301
- section.linked_resources = acceptanceTest.linked_resources;
302
- return section;
303
- });
304
- }
305
218
  listOfRequirements.push(requirementSection);
306
219
  }
307
220
  plainSource[heading] = listOfRequirements;
308
221
  }
222
+ if (FUNCTIONAL_REQUIREMENTS in input) {
223
+ plainSource[FUNCTIONAL_REQUIREMENTS] = functionalitiesOf(input).map(marshallFunctionality);
224
+ }
309
225
  return plainSource;
310
226
  }
311
227
  export function normalizeLineEndings(plainSourceText) {
@@ -326,12 +242,14 @@ export function restoreStrippedLines(plainSourceText, content) {
326
242
  export function readPlainSourceMetadata(plainSourceText) {
327
243
  let metadata = {};
328
244
  let content = plainSourceText;
329
- const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(plainSourceText);
245
+ // python-frontmatter's boundary: a line of three or more dashes, the block between them possibly empty.
246
+ const match = /^-{3,}[ \t]*\n(?:([\s\S]*?)\n)?-{3,}[ \t]*(?:\n|$)/.exec(plainSourceText);
330
247
  if (match) {
331
248
  try {
332
249
  // python-frontmatter yields {} when the block does not parse to a mapping,
333
250
  // so a malformed directive block is ignored rather than fatal.
334
- const loaded = loadYaml(match[1]);
251
+ const block = match[1] ?? "";
252
+ const loaded = block.trim() === "" ? {} : loadYaml(block);
335
253
  metadata =
336
254
  loaded !== null && typeof loaded === "object" && !Array.isArray(loaded)
337
255
  ? loaded
@@ -368,6 +286,141 @@ export function readPlainSourceMetadata(plainSourceText) {
368
286
  }
369
287
  return { metadata, content };
370
288
  }
289
+ /** `Syntax error at line N in module 'm'`, without the parts that are not known. */
290
+ export function syntaxErrorAt(line, moduleName) {
291
+ let where = "Syntax error";
292
+ if (line !== null && line !== undefined)
293
+ where += ` at line ${line}`;
294
+ if (moduleName)
295
+ where += ` in module '${moduleName}'`;
296
+ return where;
297
+ }
298
+ export const TEST_PLAIN_FILE_SECTION_HEADINGS = [CONFORMANCE_TEST_HEADING, ACCEPTANCE_TEST_HEADING];
299
+ /** Throw when a `***conformance tests***` or `***acceptance tests***` marker is anywhere in a spec plain file. */
300
+ export function checkNoTestSections(plainSourceFullText, moduleName) {
301
+ plainSourceFullText.split("\n").forEach((line, index) => {
302
+ for (const heading of TEST_PLAIN_FILE_SECTION_HEADINGS) {
303
+ if (line.trim() === `***${heading}***`) {
304
+ throw new PlainSyntaxError(`Plain syntax error: ${syntaxErrorAt(index + 1, moduleName)}: The \`${heading}\` section is not ` +
305
+ "allowed in a spec plain file; test scenarios live in test plain files.");
306
+ }
307
+ }
308
+ });
309
+ }
310
+ /** The name in a `***name***` paragraph, or null when the paragraph is not a section marker. */
311
+ export function sectionMarker(token) {
312
+ const paragraphChildren = token.children ?? [];
313
+ if (paragraphChildren.length !== 1 || paragraphChildren[0].type !== "emphasis")
314
+ return null;
315
+ const emphasisChildren = paragraphChildren[0].children ?? [];
316
+ if (emphasisChildren.length !== 1 || emphasisChildren[0].type !== "strong")
317
+ return null;
318
+ return String((emphasisChildren[0].children ?? [])[0]?.value ?? "");
319
+ }
320
+ /** Whether a block is an ATX heading - `# text` - as mistletoe's `Heading`; a setext one is not. */
321
+ function isAtxHeading(token) {
322
+ return token.type === "heading" && token.sourceLine === undefined;
323
+ }
324
+ /**
325
+ * Add the sections of the rendered spec plain file to `plainSource`.
326
+ *
327
+ * A list section holds a section whose children are its list items; `functional specs` holds a list of
328
+ * functionalities, one per `# <func-id>` heading, each with every block up to the next heading or section marker.
329
+ * Throw on the first structural error.
330
+ */
331
+ export function readSections(plainSource, document, moduleName) {
332
+ let currentSpecificationHeading = null;
333
+ let currentFunctionality = null;
334
+ const processedSpecificationHeadings = new Set();
335
+ // Func-id to the line of its heading.
336
+ const functionalityLines = new Map();
337
+ const closeFunctionality = () => {
338
+ if (currentFunctionality !== null && currentFunctionality.body.length === 0) {
339
+ throw new PlainSyntaxError(`Plain syntax error: ${syntaxErrorAt(currentFunctionality.lineNumber, moduleName)}: ` +
340
+ `Functionality \`${currentFunctionality.id}\` has no body.`);
341
+ }
342
+ };
343
+ for (const token of document.children ?? []) {
344
+ const tokenText = getRawText(token).trim();
345
+ const where = syntaxErrorAt(lineNumber(token), moduleName);
346
+ const inFunctionalSpecs = currentSpecificationHeading === FUNCTIONAL_REQUIREMENTS;
347
+ if (isAtxHeading(token)) {
348
+ if (!inFunctionalSpecs) {
349
+ throw new PlainSyntaxError(`Plain syntax error: ${where}: A \`# <func-id>\` heading is allowed only in the ` +
350
+ `${FUNCTIONAL_REQUIREMENTS} section (\`${tokenText}\`).`);
351
+ }
352
+ if (token.depth !== 1) {
353
+ throw new PlainSyntaxError(`Plain syntax error: ${where}: Invalid heading level ${token.depth} (\`${tokenText}\`). ` +
354
+ "A functionality heading is `# <func-id>`.");
355
+ }
356
+ if (!isSemanticId(tokenText)) {
357
+ throw new PlainSyntaxError(`Plain syntax error: ${where}: Invalid func-id (\`${tokenText}\`). A func-id is lowercase letters ` +
358
+ "and digits, with single hyphens between them.");
359
+ }
360
+ if (functionalityLines.has(tokenText)) {
361
+ throw new PlainSyntaxError(`Plain syntax error: ${where}: Duplicate func-id \`${tokenText}\`; it is first used at line ` +
362
+ `${functionalityLines.get(tokenText)}.`);
363
+ }
364
+ closeFunctionality();
365
+ functionalityLines.set(tokenText, lineNumber(token));
366
+ currentFunctionality = { id: tokenText, lineNumber: lineNumber(token), body: [], linkedResources: [] };
367
+ functionalitiesOf(plainSource).push(currentFunctionality);
368
+ }
369
+ else if (token.type === "paragraph") {
370
+ const specificationHeading = sectionMarker(token);
371
+ if (specificationHeading === null) {
372
+ if (!inFunctionalSpecs) {
373
+ throw new PlainSyntaxError(`Plain syntax error: ${where}: Invalid specification (\`${tokenText}\`)`);
374
+ }
375
+ if (currentFunctionality === null) {
376
+ throw new PlainSyntaxError(`Plain syntax error: ${where}: Content before the first \`# <func-id>\` heading in the ` +
377
+ `${FUNCTIONAL_REQUIREMENTS} section (\`${tokenText}\`). ${PREVIOUS_SYNTAX_HINT}`);
378
+ }
379
+ currentFunctionality.body.push(token);
380
+ continue;
381
+ }
382
+ if (!ALLOWED_SPECIFICATION_HEADINGS.includes(specificationHeading)) {
383
+ throw new PlainSyntaxError(`Plain syntax error: ${where}: Invalid specification heading (\`${specificationHeading}\`). ` +
384
+ `Allowed headings: ${ALLOWED_SPECIFICATION_HEADINGS.join(", ")}`);
385
+ }
386
+ if (processedSpecificationHeadings.has(specificationHeading)) {
387
+ throw new PlainSyntaxError(`Plain syntax error: ${where}: Duplicate specification heading (\`${specificationHeading}\`)`);
388
+ }
389
+ if (specificationHeading === DEFINITIONS && currentSpecificationHeading !== null) {
390
+ throw new PlainSyntaxError(`Plain syntax error: ${where}: Definitions specification must be the first specification in ` +
391
+ `the section (\`${tokenText}\`)`);
392
+ }
393
+ closeFunctionality();
394
+ currentFunctionality = null;
395
+ currentSpecificationHeading = specificationHeading;
396
+ if (specificationHeading === FUNCTIONAL_REQUIREMENTS) {
397
+ plainSource[specificationHeading] = [];
398
+ }
399
+ else if (plainSource[specificationHeading] == null) {
400
+ plainSource[specificationHeading] = { children: [] };
401
+ }
402
+ processedSpecificationHeadings.add(specificationHeading);
403
+ }
404
+ else if (token.type === "list" && !inFunctionalSpecs) {
405
+ if (currentSpecificationHeading === null) {
406
+ throw new PlainSyntaxError(`Plain syntax error: ${where}: Missing specification heading (\`${tokenText}\`)`);
407
+ }
408
+ sectionOf(plainSource, currentSpecificationHeading).children.push(...(token.children ?? []));
409
+ }
410
+ else if (inFunctionalSpecs) {
411
+ // The body of a functionality is free text: a list at any indentation, a code block, or any other block.
412
+ if (currentFunctionality === null) {
413
+ throw new PlainSyntaxError(`Plain syntax error: ${where}: Content before the first \`# <func-id>\` heading in the ` +
414
+ `${FUNCTIONAL_REQUIREMENTS} section (\`${tokenText}\`). ${PREVIOUS_SYNTAX_HINT}`);
415
+ }
416
+ currentFunctionality.body.push(token);
417
+ }
418
+ else {
419
+ throw new PlainSyntaxError(`Plain syntax error: ${where}: Invalid source structure (\`${tokenText}\`)`);
420
+ }
421
+ }
422
+ closeFunctionality();
423
+ }
371
424
  function processImports(plainSource, imports, codeVariables, templateDirs, importedModules, modulesTrace, importedFrom) {
372
425
  const requiredConcepts = [];
373
426
  for (const moduleName of imports) {
@@ -382,8 +435,8 @@ function processImports(plainSource, imports, codeVariables, templateDirs, impor
382
435
  "Imported modules may only provide definitions, implementation reqs, and test reqs — " +
383
436
  "use 'requires' instead of 'import' if this module's functionalities should be built on.");
384
437
  }
385
- for (const section of Object.values(result.plainSource)) {
386
- for (const item of section?.children ?? [])
438
+ for (const heading of LIST_SECTION_HEADINGS) {
439
+ for (const item of sectionOf(result.plainSource, heading)?.children ?? [])
387
440
  importedFrom.set(item, result.importedFrom.get(item) ?? moduleName);
388
441
  }
389
442
  for (const heading of Object.keys(result.plainSource)) {
@@ -395,7 +448,7 @@ function processImports(plainSource, imports, codeVariables, templateDirs, impor
395
448
  plainSource[heading] = result.plainSource[heading] ?? null;
396
449
  }
397
450
  else if (result.plainSource[heading] != null) {
398
- plainSource[heading].children.push(...result.plainSource[heading].children);
451
+ sectionOf(plainSource, heading).children.push(...sectionOf(result.plainSource, heading).children);
399
452
  }
400
453
  }
401
454
  if (REQUIRED_CONCEPTS_DIRECTIVE in result.plainSourceObj.metadata) {
@@ -412,7 +465,7 @@ function processImports(plainSource, imports, codeVariables, templateDirs, impor
412
465
  }
413
466
  return requiredConcepts;
414
467
  }
415
- export function parsePlainSource(plainSourceText, codeVariables, templateDirs, importedModules, modulesTrace) {
468
+ export function parsePlainSource(plainSourceText, codeVariables, templateDirs, importedModules, modulesTrace, moduleName) {
416
469
  plainSourceText = normalizeLineEndings(plainSourceText);
417
470
  const plainSourceObj = readPlainSourceMetadata(plainSourceText);
418
471
  const plainSource = newPlainSource();
@@ -421,81 +474,53 @@ export function parsePlainSource(plainSourceText, codeVariables, templateDirs, i
421
474
  if (IMPORT_DIRECTIVE in plainSourceObj.metadata) {
422
475
  requiredConcepts = processImports(plainSource, plainSourceObj.metadata[IMPORT_DIRECTIVE], codeVariables, templateDirs, importedModules, modulesTrace, importedFrom);
423
476
  }
424
- const [, loadedTemplates] = getLoadedTemplates(templateDirs, plainSourceText);
477
+ const [, loadedTemplates] = getLoadedTemplates(templateDirs, plainSourceText, moduleName);
425
478
  const plainSourceContent = restoreStrippedLines(plainSourceText, plainSourceObj.content);
426
479
  const plainSourceFullText = renderPlainSource(plainSourceContent, loadedTemplates, codeVariables);
480
+ checkNoTestSections(plainSourceFullText, moduleName);
481
+ // Blank lines are kept as the reference keeps them, parsed inside its renderer: one BlankLine token per blank line.
427
482
  const plainFile = parseMarkdown(plainSourceFullText);
428
483
  removeQuotes(plainFile);
429
- let currentSpecificationHeading = null;
430
- const processedSpecificationHeadings = new Set();
431
- for (const token of plainFile.children ?? []) {
432
- const tokenText = getRawText(token).trim();
433
- if (token.type === "paragraph") {
434
- const paragraphChildren = token.children ?? [];
435
- const emphasisChildren = paragraphChildren.length > 0 && paragraphChildren[0].type === "emphasis"
436
- ? (paragraphChildren[0].children ?? [])
437
- : [];
438
- if (!(paragraphChildren.length === 1 &&
439
- paragraphChildren[0].type === "emphasis" &&
440
- emphasisChildren.length === 1 &&
441
- emphasisChildren[0].type === "strong")) {
442
- throw new PlainSyntaxError(`Plain syntax error: Syntax error at line ${lineNumber(token)}: Invalid specification (\`${tokenText}\`)`);
443
- }
444
- const specificationHeading = String((emphasisChildren[0].children ?? [])[0]?.value ?? "");
445
- if (specificationHeading === ACCEPTANCE_TEST_HEADING) {
446
- throw new PlainSyntaxError(`Plain syntax error: Syntax error at line ${lineNumber(token)}: ${ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec.`);
447
- }
448
- if (!ALLOWED_SPECIFICATION_HEADINGS.includes(specificationHeading)) {
449
- throw new PlainSyntaxError(`Plain syntax error: Syntax error at line ${lineNumber(token)}: Invalid specification heading (\`${specificationHeading}\`). Allowed headings: ${ALLOWED_SPECIFICATION_HEADINGS.join(", ")}`);
450
- }
451
- if (specificationHeading === FUNCTIONAL_REQUIREMENTS && !(specificationHeading in plainSource)) {
452
- plainSource[specificationHeading] = null;
453
- }
454
- if (processedSpecificationHeadings.has(specificationHeading)) {
455
- throw new PlainSyntaxError(`Plain syntax error: Syntax error at line ${lineNumber(token)}: Duplicate specification heading (\`${specificationHeading}\`)`);
456
- }
457
- if (specificationHeading === DEFINITIONS && currentSpecificationHeading !== null) {
458
- throw new PlainSyntaxError(`Plain syntax error: Syntax error at line ${lineNumber(token)}: Definitions specification must be the first specification in the section (\`${tokenText}\`)`);
459
- }
460
- currentSpecificationHeading = specificationHeading;
461
- if (plainSource[currentSpecificationHeading] == null) {
462
- plainSource[currentSpecificationHeading] = { children: [] };
463
- }
464
- processedSpecificationHeadings.add(currentSpecificationHeading);
465
- }
466
- else if (token.type === "list") {
467
- if (currentSpecificationHeading === null) {
468
- throw new PlainSyntaxError(`Plain syntax error: Syntax error at line ${lineNumber(token)}: Missing specification heading (\`${tokenText}\`)`);
469
- }
470
- plainSource[currentSpecificationHeading].children.push(...(token.children ?? []));
471
- }
472
- else {
473
- throw new PlainSyntaxError(`Plain syntax error: Syntax error at line ${lineNumber(token)}: Invalid source structure (\`${tokenText}\`)`);
474
- }
475
- }
476
- // Acceptance tests belong under functional specs only.
477
- for (const nonFunctionalHeading of [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS]) {
478
- const section = plainSource[nonFunctionalHeading];
479
- if (!section)
480
- continue;
481
- for (const child of section.children) {
482
- const nested = findAcceptanceTestHeading(child);
483
- if (nested !== null) {
484
- throw new PlainSyntaxError(`Plain syntax error: Syntax error at line ${lineNumber(nested)}: ${ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec.`);
485
- }
486
- }
487
- }
488
- const definitionsSection = plainSource[DEFINITIONS];
484
+ readSections(plainSource, plainFile, moduleName);
485
+ const definedConcepts = new Set();
486
+ const definitionsSection = sectionOf(plainSource, DEFINITIONS);
489
487
  if (definitionsSection) {
490
488
  for (const token of definitionsSection.children) {
491
489
  const [newConcepts] = extractConceptsFromDefinition(renderToMarkdown(token));
492
490
  for (const concept of newConcepts) {
491
+ definedConcepts.add(concept);
493
492
  const index = requiredConcepts.indexOf(concept);
494
493
  if (index !== -1)
495
494
  requiredConcepts.splice(index, 1);
496
495
  }
497
496
  }
498
497
  }
498
+ if (EXPORTED_CONCEPTS_DIRECTIVE in plainSourceObj.metadata) {
499
+ const tracedModule = modulesTrace[modulesTrace.length - 1];
500
+ const subject = tracedModule ? `Module '${tracedModule}'` : "Module";
501
+ for (const entry of plainSourceObj.metadata[EXPORTED_CONCEPTS_DIRECTIVE]) {
502
+ const [exportedConcepts, errors] = extractConceptsFromDefinition(entry);
503
+ if (errors.length > 0) {
504
+ throw new PlainSyntaxError(`Plain syntax error: ${subject} exports an invalid concept (\`${entry}\`). ` +
505
+ "Expected a concept token, e.g. :ConceptName:.");
506
+ }
507
+ for (const exportedConcept of exportedConcepts) {
508
+ if (DEFAULT_CONCEPTS.has(exportedConcept)) {
509
+ throw new PlainSyntaxError(`Plain syntax error: ${subject} cannot export default concept ${exportedConcept}. ` +
510
+ "Only user-defined concepts can be exported.");
511
+ }
512
+ if (!definedConcepts.has(exportedConcept)) {
513
+ let message = `Plain syntax error: ${subject} exports undefined concept ${exportedConcept}.`;
514
+ if (REQUIRES_DIRECTIVE in plainSourceObj.metadata) {
515
+ message +=
516
+ " An exported concept must be defined in this module's definitions or in an " +
517
+ "imported module; a concept inherited through requires cannot be re-exported.";
518
+ }
519
+ throw new PlainSyntaxError(message);
520
+ }
521
+ }
522
+ }
523
+ }
499
524
  const requiredModules = plainSourceObj.metadata[REQUIRES_DIRECTIVE] ?? [];
500
525
  return { plainSource, plainSourceObj, requiredModules, requiredConcepts, importedFrom };
501
526
  }
@@ -515,29 +540,34 @@ export function readModulePlainSource(moduleName, templateDirs) {
515
540
  const blob = findLargeBase64Blob(plainSourceText);
516
541
  if (blob !== null) {
517
542
  throw new UnsupportedBase64Content(`Module '${moduleName}' contains a base64-encoded blob (${blob.length} characters) ` +
518
- "inlined in the specification. This is not supported." +
519
- "Remove the base64 data from the .plain file or if necessary," +
543
+ "inlined in the specification. This is not supported. " +
544
+ "Remove the base64 data from the .plain file or if necessary, " +
520
545
  "include the binary file path in the specification.");
521
546
  }
522
547
  return plainSourceText;
523
548
  }
524
549
  export function parsePlainFile(moduleName, codeVariables, templateDirs, importedModules, modulesTrace) {
525
550
  const plainSourceText = readModulePlainSource(moduleName, templateDirs);
526
- return parsePlainSource(plainSourceText, codeVariables, templateDirs, importedModules, [
527
- ...modulesTrace,
528
- moduleName,
529
- ]);
551
+ return parsePlainSource(plainSourceText, codeVariables, templateDirs, importedModules, [...modulesTrace, moduleName], moduleName);
530
552
  }
531
- export function processRequiredModules(requiredModules, codeVariables, templateDirs, allRequiredModules, modulesTrace) {
553
+ /**
554
+ * Parse every required module, deepest first, without validating it - each with code variables of its own - and
555
+ * return the definitions the directly required modules export. A module listed again right after it was walked is
556
+ * not parsed again, and what it exports is still visible to this module. `exports` records what each module walked
557
+ * exports, so a repeat can find it.
558
+ */
559
+ export function processRequiredModules(requiredModules, templateDirs, allRequiredModules, modulesTrace, exports = new Map()) {
532
560
  const exportedDefinitions = [];
533
561
  for (const moduleName of requiredModules) {
534
562
  if (modulesTrace.includes(moduleName)) {
535
563
  throw new PlainSyntaxError(`Plain syntax error: Circular required module detected: ${moduleName}.`);
536
564
  }
537
565
  if (allRequiredModules.length > 0 && moduleName === allRequiredModules[allRequiredModules.length - 1]) {
566
+ // Already walked as an earlier entry; its exports are still visible to this module.
567
+ exportedDefinitions.push(...(exports.get(moduleName) ?? []));
538
568
  continue;
539
569
  }
540
- const result = parsePlainFile(moduleName, codeVariables, templateDirs, [], []);
570
+ const result = parsePlainFile(moduleName, {}, templateDirs, [], []);
541
571
  if (result.requiredModules.length === 0) {
542
572
  if (allRequiredModules.length > 0) {
543
573
  // Required modules must form one chain; independent modules cannot yet be merged.
@@ -545,40 +575,31 @@ export function processRequiredModules(requiredModules, codeVariables, templateD
545
575
  }
546
576
  }
547
577
  else {
548
- processRequiredModules(result.requiredModules, codeVariables, templateDirs, allRequiredModules, [
549
- ...modulesTrace,
550
- moduleName,
551
- ]);
578
+ processRequiredModules(result.requiredModules, templateDirs, allRequiredModules, [...modulesTrace, moduleName], exports);
552
579
  }
553
- exportedDefinitions.push(...exportedDefinitionsOf(result));
580
+ const own = exportedDefinitionsOf(result);
581
+ exports.set(moduleName, own);
582
+ exportedDefinitions.push(...own);
554
583
  allRequiredModules.push(moduleName);
555
584
  }
556
585
  return exportedDefinitions;
557
586
  }
558
- /** The definitions a required module exports: those of its `exported_concepts`, with the definitions they use. */
587
+ /**
588
+ * The definitions a required module exports: those of its `exported_concepts`, with the definitions they use. What
589
+ * it may export is checked as it is parsed (`parsePlainSource`).
590
+ */
559
591
  export function exportedDefinitionsOf(result) {
560
592
  if (!(EXPORTED_CONCEPTS_DIRECTIVE in result.plainSourceObj.metadata))
561
593
  return [];
562
- const exportedConcepts = [];
563
- for (const concept of result.plainSourceObj.metadata[EXPORTED_CONCEPTS_DIRECTIVE]) {
564
- if (typeof concept === "string" && DEFAULT_CONCEPTS.has(concept)) {
565
- throw new PlainSyntaxError(`Plain syntax error: Default concept cannot be exported: ${concept}. Only user-defined concepts can be exported.`);
566
- }
567
- if (typeof concept === "string") {
568
- exportedConcepts.push(...extractConceptsFromDefinition(concept)[0]);
569
- }
570
- else {
571
- throw new PlainSyntaxError(`Plain syntax error: Invalid exported concept: ${concept}.`);
572
- }
573
- }
574
- return exportedConcepts.flatMap((exportedConcept) => findConceptDefinitionsInPlainSource(exportedConcept, result.plainSource[DEFINITIONS] ?? null));
594
+ const exportedConcepts = result.plainSourceObj.metadata[EXPORTED_CONCEPTS_DIRECTIVE].flatMap((concept) => extractConceptsFromDefinition(concept)[0]);
595
+ return exportedConcepts.flatMap((exportedConcept) => findConceptDefinitionsInPlainSource(exportedConcept, sectionOf(result.plainSource, DEFINITIONS) ?? null));
575
596
  }
576
597
  export function processExportedDefinitions(plainSource, exportedDefinitions) {
577
598
  if (exportedDefinitions.length === 0)
578
599
  return;
579
- const definitions = plainSource[DEFINITIONS];
580
- if (!definitions)
581
- return;
600
+ if (!plainSource[DEFINITIONS])
601
+ plainSource[DEFINITIONS] = { children: [] };
602
+ const definitions = sectionOf(plainSource, DEFINITIONS);
582
603
  for (const exportedDefinition of exportedDefinitions) {
583
604
  const exportedRendered = renderToMarkdown(exportedDefinition).trim();
584
605
  const alreadyPresent = definitions.children.some((definition) => renderToMarkdown(definition).trim() === exportedRendered);
@@ -587,17 +608,18 @@ export function processExportedDefinitions(plainSource, exportedDefinitions) {
587
608
  }
588
609
  }
589
610
  export function plainFileParser(plainSourceFileName, templateDirs, options = {}) {
590
- const key = `${options.allowNoFunctionalities ? "any" : "renderable"}\0${process.cwd()}\0${templateDirs.join("\0")}\0${plainSourceFileName}`;
611
+ const tests = options.testDir === undefined ? "" : `${resolve(options.testDir)}${options.requireTestPlainFiles === true ? "!" : ""}`;
612
+ const key = `${options.allowNoFunctionalities ? "any" : "renderable"}\0${process.cwd()}\0${templateDirs.join("\0")}\0${plainSourceFileName}\0${tests}`;
591
613
  if (activeCache === undefined) {
592
614
  // Between calls: the parse from before, while nothing it could have read has changed since.
593
- const fingerprint = filesFingerprint(templateDirs);
615
+ const fingerprint = filesFingerprint(templateDirs, options.testDir);
594
616
  const kept = lastParses.get(key);
595
617
  if (stillHolds(kept, fingerprint)) {
596
618
  if (kept.error !== undefined)
597
619
  throw kept.error;
598
620
  return structuredClone(kept.parsed);
599
621
  }
600
- const result = parseNoting(() => parseModule(plainSourceFileName, templateDirs, new Map(), options));
622
+ const result = parseNoting(() => parseWithTestPlainFiles(plainSourceFileName, templateDirs, new Map(), options));
601
623
  // A spec that does not parse does not parse again until it, or a file it links, changes: it is not parsed again to say so.
602
624
  if (fingerprint !== undefined)
603
625
  remember(key, { fingerprint, linked: result.linked, linkedStamp: linkedStamp(result.linked), ...(result.error !== undefined ? { error: result.error } : { parsed: structuredClone(result.parsed) }) });
@@ -608,10 +630,10 @@ export function plainFileParser(plainSourceFileName, templateDirs, options = {})
608
630
  let parsed = activeCache.get(key);
609
631
  if (parsed === undefined) {
610
632
  // Nothing changes while `run` runs: each folder set's fingerprint is taken once.
611
- const dirsKey = templateDirs.join("\0");
633
+ const dirsKey = `${templateDirs.join("\0")}\0${options.testDir ?? ""}`;
612
634
  let fingerprint = activeCache.fingerprints.get(dirsKey);
613
635
  if (!activeCache.fingerprints.has(dirsKey)) {
614
- fingerprint = filesFingerprint(templateDirs);
636
+ fingerprint = filesFingerprint(templateDirs, options.testDir);
615
637
  activeCache.fingerprints.set(dirsKey, fingerprint);
616
638
  }
617
639
  const kept = lastParses.get(key);
@@ -622,7 +644,7 @@ export function plainFileParser(plainSourceFileName, templateDirs, options = {})
622
644
  }
623
645
  else {
624
646
  const modules = activeCache.modules;
625
- const result = parseNoting(() => parseModule(plainSourceFileName, templateDirs, modules, options));
647
+ const result = parseNoting(() => parseWithTestPlainFiles(plainSourceFileName, templateDirs, modules, options));
626
648
  if (fingerprint !== undefined)
627
649
  remember(key, { fingerprint, linked: result.linked, linkedStamp: linkedStamp(result.linked), ...(result.error !== undefined ? { error: result.error } : { parsed: structuredClone(result.parsed) }) });
628
650
  if (result.error !== undefined)
@@ -633,6 +655,33 @@ export function plainFileParser(plainSourceFileName, templateDirs, options = {})
633
655
  }
634
656
  return parsed;
635
657
  }
658
+ /**
659
+ * Port of plain_file.parse_module_chain: a module and every module in its `requires` chain, each as its name and its
660
+ * tree - every required module first, deepest ancestors first, then the module itself. A module reached through more
661
+ * than one `requires` path appears once, at its first (deepest) position.
662
+ */
663
+ export function parseModuleChain(plainSourceFileName, templateDirs, options = {}) {
664
+ return withParseCache(() => {
665
+ const [moduleName, tree, requiredModules] = plainFileParser(plainSourceFileName, templateDirs, options);
666
+ const chain = [];
667
+ const walked = new Set();
668
+ const walk = (names) => {
669
+ for (const name of names) {
670
+ if (walked.has(name))
671
+ continue;
672
+ const [requiredName, requiredTree, itsRequires] = plainFileParser(`${name}${PLAIN_SOURCE_FILE_EXTENSION}`, templateDirs);
673
+ walk(itsRequires);
674
+ if (walked.has(requiredName))
675
+ continue;
676
+ walked.add(requiredName);
677
+ chain.push([requiredName, requiredTree]);
678
+ }
679
+ };
680
+ walk(requiredModules);
681
+ chain.push([moduleName, tree]);
682
+ return chain;
683
+ });
684
+ }
636
685
  /**
637
686
  * One parse per file for the length of `run`, however many times it is asked for.
638
687
  *
@@ -662,9 +711,13 @@ function remember(key, entry) {
662
711
  while (lastParses.size > KEPT_PARSES)
663
712
  lastParses.delete(lastParses.keys().next().value);
664
713
  }
665
- function filesFingerprint(templateDirs) {
714
+ function filesFingerprint(templateDirs, testDir) {
666
715
  try {
667
716
  const parts = [];
717
+ // Every .plain file under the test folder, by name and text: a scenario written, changed or removed is seen.
718
+ if (testDir !== undefined)
719
+ for (const path of plainFilesUnder(resolve(testDir)))
720
+ parts.push(`${path}:${readFileSync(path, "utf8")}`);
668
721
  for (const dir of [process.cwd(), ...templateDirs]) {
669
722
  let entries;
670
723
  try {
@@ -688,6 +741,74 @@ function filesFingerprint(templateDirs) {
688
741
  return undefined;
689
742
  }
690
743
  }
744
+ /** Every .plain file under a folder, at any depth, sorted; none when it is not there. */
745
+ function plainFilesUnder(dir) {
746
+ let entries;
747
+ try {
748
+ entries = readdirSync(dir, { withFileTypes: true });
749
+ }
750
+ catch {
751
+ return [];
752
+ }
753
+ const found = [];
754
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
755
+ const path = join(dir, entry.name);
756
+ if (entry.isDirectory())
757
+ found.push(...plainFilesUnder(path));
758
+ else if (entry.name.endsWith(PLAIN_SOURCE_FILE_EXTENSION))
759
+ found.push(path);
760
+ }
761
+ return found;
762
+ }
763
+ /**
764
+ * A module parsed, and - with `options.testDir` - its chain's test plain files bound to it: the scenarios on its
765
+ * functional specs and on the ones it inherits, `TEST_PLAIN_FILES` naming each file and what it binds to.
766
+ */
767
+ function parseWithTestPlainFiles(plainSourceFileName, templateDirs, cache, options) {
768
+ const [moduleName, tree, requiredModules] = parseModule(plainSourceFileName, templateDirs, cache, options);
769
+ if (options.testDir === undefined)
770
+ return [moduleName, tree, requiredModules];
771
+ // The chain, deepest first, each tree a copy the scenarios are bound into.
772
+ const chain = [];
773
+ const walked = new Set();
774
+ const walk = (names) => {
775
+ for (const name of names) {
776
+ if (walked.has(name))
777
+ continue;
778
+ let parsed = cache.get(name);
779
+ if (parsed === undefined) {
780
+ parsed = parseModule(`${name}${PLAIN_SOURCE_FILE_EXTENSION}`, templateDirs, cache);
781
+ cache.set(name, parsed);
782
+ }
783
+ walk(parsed[2]);
784
+ if (walked.has(parsed[0]))
785
+ continue;
786
+ walked.add(parsed[0]);
787
+ chain.push([parsed[0], structuredClone(parsed[1])]);
788
+ }
789
+ };
790
+ walk(requiredModules);
791
+ const own = structuredClone(tree);
792
+ chain.push([moduleName, own]);
793
+ const testDirs = chain.map(([name]) => join(options.testDir, name));
794
+ const bound = bindTestPlainFiles(chain, testDirs, templateDirs, templateDirs[0] ?? ".");
795
+ if (options.requireTestPlainFiles === true)
796
+ checkTestPlainFiles(chain, bound);
797
+ // What the module inherits carries the scenarios bound in the module it is written in.
798
+ const boundItems = new Map();
799
+ for (const [name, chainTree] of chain)
800
+ for (const item of getFunctionalSpecs(chainTree))
801
+ boundItems.set(`${name}\0${item.id}`, item);
802
+ const inherited = own[REQUIRED_FUNCTIONAL_SPECS];
803
+ if (inherited !== undefined) {
804
+ own[REQUIRED_FUNCTIONAL_SPECS] = inherited.map((item) => {
805
+ const boundItem = boundItems.get(`${item.module}\0${item.id}`);
806
+ return boundItem === undefined ? item : { ...item, [CONFORMANCE_TESTS]: boundItem[CONFORMANCE_TESTS], [ACCEPTANCE_TESTS]: boundItem[ACCEPTANCE_TESTS] };
807
+ });
808
+ }
809
+ own[TEST_PLAIN_FILES] = Object.fromEntries(bound.map((file) => [file.name, { module: file.parsed.module, func_id: file.parsed.funcId }]));
810
+ return [moduleName, own, requiredModules];
811
+ }
691
812
  /**
692
813
  * The functional specs a module inherits through `requires`, bottom of the chain first.
693
814
  *
@@ -701,7 +822,7 @@ function inheritFunctionalSpecs(requires, templateDirs, cache) {
701
822
  const inherited = [];
702
823
  const seen = new Set();
703
824
  const add = (item) => {
704
- const key = `${item.module}/${item.frid}`;
825
+ const key = `${item.module}/${item.id}`;
705
826
  if (seen.has(key))
706
827
  return;
707
828
  seen.add(key);
@@ -716,7 +837,8 @@ function inheritFunctionalSpecs(requires, templateDirs, cache) {
716
837
  const [moduleName, tree] = parsed;
717
838
  for (const item of tree[REQUIRED_FUNCTIONAL_SPECS] ?? [])
718
839
  add(item);
719
- (getSpecificationItems(tree, FUNCTIONAL_REQUIREMENTS) ?? []).forEach((item, index) => add({ ...item, module: moduleName, frid: String(index + 1) }));
840
+ for (const item of getFunctionalSpecs(tree))
841
+ add({ ...item, module: moduleName });
720
842
  }
721
843
  return inherited;
722
844
  }
@@ -741,13 +863,12 @@ function parseModule(plainSourceFileName, templateDirs, cache, options = {}) {
741
863
  "At least one functionality is required for rendering.");
742
864
  }
743
865
  if (!options.allowNoFunctionalities && countFunctionalities(result.plainSource) === 0) {
744
- throw new PlainSyntaxError(`Plain syntax error: Module '${moduleName}' has an empty '${FUNCTIONAL_REQUIREMENTS}' section. ` +
745
- "At least one functionality must be specified.");
866
+ throw new PlainSyntaxError(`Plain syntax error: Module '${moduleName}' has a '${FUNCTIONAL_REQUIREMENTS}' section ` +
867
+ `without a \`# <func-id>\` heading. ${PREVIOUS_SYNTAX_HINT}`);
746
868
  }
747
869
  validateFunctionalitiesHaveImplementationReqs(result.plainSource, moduleName, result.requiredModules.length > 0);
748
- const exportedDefinitions = processRequiredModules(result.requiredModules, {}, templateDirs, [], []);
870
+ const exportedDefinitions = processRequiredModules(result.requiredModules, templateDirs, [], []);
749
871
  processExportedDefinitions(result.plainSource, exportedDefinitions);
750
- processAcceptanceTests(result.plainSource);
751
872
  checkForLinkedResources(result.plainSource, templateDirs);
752
873
  const marshalled = marshallPlainSource(result.plainSource);
753
874
  processCodeVariables(marshalled, codeVariables);