@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
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Port of graph.py: the cycle search over a directed graph given as a mapping from a node to its successors.
3
+ *
4
+ * Nodes that appear only as successors are treated as having no outgoing edges. The search is Johnson's algorithm,
5
+ * with the strongly connected components found by Tarjan's. Both keep their own stack of frames instead of
6
+ * recursing, so the depth of a tangle is not limited by the call stack.
7
+ *
8
+ * - D. B. Johnson, "Finding all the elementary circuits of a directed graph", SIAM J. Comput. 4(1):77-84, 1975
9
+ * - R. Tarjan, "Depth-first search and linear graph algorithms", SIAM J. Comput. 1(2):146-160, 1972
10
+ */
11
+ function successorsOf(graph) {
12
+ // A repeated edge would otherwise yield every cycle through it once per repeat.
13
+ const successors = new Map();
14
+ for (const [node, targets] of graph)
15
+ successors.set(node, [...new Set(targets)]);
16
+ for (const targets of [...successors.values()]) {
17
+ for (const target of targets)
18
+ if (!successors.has(target))
19
+ successors.set(target, []);
20
+ }
21
+ return successors;
22
+ }
23
+ /**
24
+ * Tarjan: one depth-first walk, each node stamped with its visit index and the lowest index it can reach; a node
25
+ * whose lowest reachable index is its own closes a component.
26
+ */
27
+ export function stronglyConnectedComponents(graph) {
28
+ const successors = successorsOf(graph);
29
+ const index = new Map();
30
+ const lowlink = new Map();
31
+ const stack = [];
32
+ const onStack = new Set();
33
+ const components = [];
34
+ const visit = (node) => {
35
+ index.set(node, index.size);
36
+ lowlink.set(node, index.get(node));
37
+ stack.push(node);
38
+ onStack.add(node);
39
+ return { node, targets: successors.get(node), next: 0 };
40
+ };
41
+ for (const root of successors.keys()) {
42
+ if (index.has(root))
43
+ continue;
44
+ const frames = [visit(root)];
45
+ while (frames.length > 0) {
46
+ const frame = frames[frames.length - 1];
47
+ let descended = false;
48
+ while (frame.next < frame.targets.length) {
49
+ const target = frame.targets[frame.next++];
50
+ if (!index.has(target)) {
51
+ frames.push(visit(target));
52
+ descended = true;
53
+ break;
54
+ }
55
+ if (onStack.has(target))
56
+ lowlink.set(frame.node, Math.min(lowlink.get(frame.node), index.get(target)));
57
+ }
58
+ if (descended)
59
+ continue;
60
+ frames.pop();
61
+ const node = frame.node;
62
+ if (frames.length > 0) {
63
+ const parent = frames[frames.length - 1].node;
64
+ lowlink.set(parent, Math.min(lowlink.get(parent), lowlink.get(node)));
65
+ }
66
+ if (lowlink.get(node) === index.get(node)) {
67
+ const component = new Set();
68
+ for (;;) {
69
+ const member = stack.pop();
70
+ onStack.delete(member);
71
+ component.add(member);
72
+ if (member === node)
73
+ break;
74
+ }
75
+ components.push(component);
76
+ }
77
+ }
78
+ }
79
+ return components;
80
+ }
81
+ /**
82
+ * Johnson's CIRCUIT: every elementary cycle through `s` in one strongly connected component. A node is blocked while
83
+ * it is on the path, and stays blocked after backtracking until a cycle is found through some node it was waiting on.
84
+ */
85
+ function* cyclesThrough(adjacency, s) {
86
+ const blocked = new Set();
87
+ const blockedMap = new Map();
88
+ const unblock = (node) => {
89
+ const pending = [node];
90
+ while (pending.length > 0) {
91
+ const current = pending.pop();
92
+ blocked.delete(current);
93
+ const waiting = blockedMap.get(current);
94
+ blockedMap.delete(current);
95
+ for (const other of waiting ?? [])
96
+ if (blocked.has(other))
97
+ pending.push(other);
98
+ }
99
+ };
100
+ const path = [s];
101
+ blocked.add(s);
102
+ const frames = [{ targets: adjacency.get(s), next: 0 }];
103
+ const foundBelow = [false];
104
+ while (frames.length > 0) {
105
+ const node = path[path.length - 1];
106
+ const frame = frames[frames.length - 1];
107
+ let descended = false;
108
+ while (frame.next < frame.targets.length) {
109
+ const target = frame.targets[frame.next++];
110
+ if (target === s) {
111
+ yield [...path];
112
+ foundBelow[foundBelow.length - 1] = true;
113
+ }
114
+ else if (!blocked.has(target)) {
115
+ path.push(target);
116
+ blocked.add(target);
117
+ frames.push({ targets: adjacency.get(target), next: 0 });
118
+ foundBelow.push(false);
119
+ descended = true;
120
+ break;
121
+ }
122
+ }
123
+ if (descended)
124
+ continue;
125
+ frames.pop();
126
+ path.pop();
127
+ if (foundBelow.pop()) {
128
+ unblock(node);
129
+ if (foundBelow.length > 0)
130
+ foundBelow[foundBelow.length - 1] = true;
131
+ }
132
+ else {
133
+ for (const target of adjacency.get(node)) {
134
+ if (!blockedMap.has(target))
135
+ blockedMap.set(target, new Set());
136
+ blockedMap.get(target).add(node);
137
+ }
138
+ }
139
+ }
140
+ }
141
+ /**
142
+ * Johnson's main loop: every cycle lies inside one strongly connected component, and every cycle inside a component
143
+ * either passes through its least node or survives that node's removal. So search each component from its least
144
+ * node, drop the node, and split what is left into components again. Components are taken least node first, so a
145
+ * caller that stops early has seen the cycles through the earliest nodes.
146
+ */
147
+ function* allCycles(graph) {
148
+ let successors = successorsOf(graph);
149
+ for (const [node, targets] of successors)
150
+ if (targets.includes(node))
151
+ yield [node];
152
+ successors = new Map([...successors].map(([node, targets]) => [node, targets.filter((target) => target !== node)]));
153
+ const position = new Map([...successors.keys()].map((node, i) => [node, i]));
154
+ const least = (component) => Math.min(...[...component].map((node) => position.get(node)));
155
+ // A priority queue by least position; components are disjoint, so no two share one.
156
+ const components = [];
157
+ const pushComponents = (adjacency) => {
158
+ for (const component of stronglyConnectedComponents(adjacency)) {
159
+ if (component.size >= 2)
160
+ components.push({ least: least(component), component });
161
+ }
162
+ components.sort((a, b) => a.least - b.least);
163
+ };
164
+ pushComponents(successors);
165
+ while (components.length > 0) {
166
+ const { component } = components.shift();
167
+ const s = [...component].reduce((a, b) => (position.get(a) <= position.get(b) ? a : b));
168
+ const adjacency = new Map();
169
+ for (const node of component)
170
+ adjacency.set(node, successors.get(node).filter((target) => component.has(target)));
171
+ yield* cyclesThrough(adjacency, s);
172
+ adjacency.delete(s);
173
+ for (const [node, targets] of adjacency)
174
+ adjacency.set(node, targets.filter((target) => target !== s));
175
+ pushComponents(adjacency);
176
+ }
177
+ }
178
+ /**
179
+ * The elementary cycles of `graph`, each as its nodes in edge order, stopping after `limit` cycles when one is given.
180
+ * Self-loops come first; longer cycles follow in order of their earliest node in the graph's iteration order.
181
+ */
182
+ export function* simpleCycles(graph, limit) {
183
+ let found = 0;
184
+ for (const cycle of allCycles(graph)) {
185
+ found += 1;
186
+ if (limit !== undefined && found > limit)
187
+ return;
188
+ yield cycle;
189
+ }
190
+ }
@@ -14,6 +14,10 @@ export declare class MissingFunctionalitiesError extends Error {
14
14
  export declare class ImportedModuleWithFunctionalitiesError extends Error {
15
15
  constructor(message: string);
16
16
  }
17
+ /** A functional spec of the chain has no test plain file bound to it. */
18
+ export declare class MissingTestPlainFileError extends Error {
19
+ constructor(message: string);
20
+ }
17
21
  export declare class InvalidFridArgument extends Error {
18
22
  constructor(message: string);
19
23
  }
@@ -29,6 +29,13 @@ export class ImportedModuleWithFunctionalitiesError extends Error {
29
29
  this.name = "ImportedModuleWithFunctionalitiesError";
30
30
  }
31
31
  }
32
+ /** A functional spec of the chain has no test plain file bound to it. */
33
+ export class MissingTestPlainFileError extends Error {
34
+ constructor(message) {
35
+ super(message);
36
+ this.name = "MissingTestPlainFileError";
37
+ }
38
+ }
32
39
  export class InvalidFridArgument extends Error {
33
40
  constructor(message) {
34
41
  super(message);
@@ -2,6 +2,12 @@
2
2
  export declare function openFrom(dirs: string[], fileName: string): string | null;
3
3
  /** The file `openFrom` reads: the first of the folders that has it. */
4
4
  export declare function findIn(dirs: string[], fileName: string): string | null;
5
+ /**
6
+ * Port of loaders.relative_resource_path: the path of `target` in `resourceDir`, relative to `baseDir`, with `/` as
7
+ * separator. The folder part is computed lexically and the target is appended as written (`..` kept), so joining
8
+ * the result onto `baseDir` names the same file as joining `target` onto `resourceDir`.
9
+ */
10
+ export declare function relativeResourcePath(baseDir: string, resourceDir: string, target: string): string;
5
11
  /** First contiguous base64 blob at or above the threshold, or null. */
6
12
  export declare function findLargeBase64Blob(text: string): string | null;
7
13
  /**
@@ -11,4 +17,15 @@ export declare function findLargeBase64Blob(text: string): string | null;
11
17
  * name to raw source, which is what the second (real) render is given so that it does
12
18
  * not touch the filesystem again.
13
19
  */
14
- export declare function getLoadedTemplates(templateDirs: string[], plainSource: string): [string, Record<string, string>];
20
+ export declare function getLoadedTemplates(templateDirs: string[], plainSource: string, templateName?: string): [string, Record<string, string>];
21
+ /**
22
+ * Port of loaders.liquid_error_message: a Liquid syntax error as a ***plain one, naming the template, and the line
23
+ * and the column (counted from 0) of the token it is at. The message after it is liquidjs's, without the position
24
+ * liquidjs appends - it phrases its errors differently from liquid2, and places some of them elsewhere.
25
+ */
26
+ export declare function liquidErrorMessage(error: Error & {
27
+ token?: {
28
+ begin?: number;
29
+ input?: string;
30
+ };
31
+ }, templateName?: string): string;
@@ -1,5 +1,7 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { ParseError, TokenizationError } from "liquidjs";
4
+ import { PlainSyntaxError } from "./errors.js";
3
5
  import { createEnvironment } from "./liquid.js";
4
6
  /** Ports of the Python reference implementation's file helpers this parser needs. */
5
7
  export function openFrom(dirs, fileName) {
@@ -20,6 +22,19 @@ export function findIn(dirs, fileName) {
20
22
  }
21
23
  return null;
22
24
  }
25
+ /**
26
+ * Port of loaders.relative_resource_path: the path of `target` in `resourceDir`, relative to `baseDir`, with `/` as
27
+ * separator. The folder part is computed lexically and the target is appended as written (`..` kept), so joining
28
+ * the result onto `baseDir` names the same file as joining `target` onto `resourceDir`.
29
+ */
30
+ export function relativeResourcePath(baseDir, resourceDir, target) {
31
+ const relativeDir = relative(resolve(baseDir), resolve(resourceDir));
32
+ if (relativeDir === "")
33
+ return target;
34
+ if (isAbsolute(relativeDir))
35
+ return join(resourceDir, target).split(sep).join("/");
36
+ return `${relativeDir.split(sep).join("/")}/${target}`;
37
+ }
23
38
  const MAX_BASE64_BLOB_LENGTH = 8192;
24
39
  const BASE64_BLOB_PATTERN = new RegExp(`(?:data:[\\w.+-]+/[\\w.+-]+;base64,)?[A-Za-z0-9+/_-]{${MAX_BASE64_BLOB_LENGTH},}={0,2}`);
25
40
  /** First contiguous base64 blob at or above the threshold, or null. */
@@ -34,7 +49,7 @@ export function findLargeBase64Blob(text) {
34
49
  * name to raw source, which is what the second (real) render is given so that it does
35
50
  * not touch the filesystem again.
36
51
  */
37
- export function getLoadedTemplates(templateDirs, plainSource) {
52
+ export function getLoadedTemplates(templateDirs, plainSource, templateName) {
38
53
  const loadedTemplates = {};
39
54
  const engine = createEnvironment({
40
55
  strictVariables: true,
@@ -51,6 +66,9 @@ export function getLoadedTemplates(templateDirs, plainSource) {
51
66
  rendered = engine.parseAndRenderSync(plainSource, {});
52
67
  }
53
68
  catch (error) {
69
+ if (error instanceof TokenizationError || error instanceof ParseError) {
70
+ throw new PlainSyntaxError(liquidErrorMessage(error, templateName));
71
+ }
54
72
  const message = error instanceof Error ? error.message : String(error);
55
73
  if (/undefined variable/i.test(message)) {
56
74
  throw new Error(`Undefined liquid variable: ${message}`);
@@ -59,3 +77,22 @@ export function getLoadedTemplates(templateDirs, plainSource) {
59
77
  }
60
78
  return [rendered, loadedTemplates];
61
79
  }
80
+ /**
81
+ * Port of loaders.liquid_error_message: a Liquid syntax error as a ***plain one, naming the template, and the line
82
+ * and the column (counted from 0) of the token it is at. The message after it is liquidjs's, without the position
83
+ * liquidjs appends - it phrases its errors differently from liquid2, and places some of them elsewhere.
84
+ */
85
+ export function liquidErrorMessage(error, templateName) {
86
+ const message = error.message.replace(/, line:\d+, col:\d+$/, "");
87
+ const token = error.token;
88
+ let where;
89
+ if (token?.input === undefined || token.begin === undefined || token.begin < 0) {
90
+ where = templateName ? ` in ${templateName}` : "";
91
+ }
92
+ else {
93
+ const line = (token.input.slice(0, token.begin).match(/\n/g) ?? []).length + 1;
94
+ const column = token.begin - (token.input.lastIndexOf("\n", token.begin - 1) + 1);
95
+ where = templateName ? ` at ${templateName}:${line}:${column}` : ` at ${line}:${column}`;
96
+ }
97
+ return `Plain syntax error: Invalid Liquid${where}: ${message}`;
98
+ }
@@ -8,6 +8,29 @@ export interface PlainGraph {
8
8
  modules: PlainModuleNode[];
9
9
  items: PlainItemNode[];
10
10
  concepts: PlainConceptNode[];
11
+ /** The test plain files in the project's test folder, each linked to the functional spec it binds to. */
12
+ testPlainFiles: PlainTestPlainFileNode[];
13
+ }
14
+ /** A scenario of a test plain file: its scenario-id, its text, and the line its `# <scenario-id>` heading is on. */
15
+ export interface PlainScenario {
16
+ id: string;
17
+ text: string;
18
+ line: number;
19
+ }
20
+ /**
21
+ * A test plain file: the functional spec it binds to - its `module:` and `func_id:`, and that functional spec's item -
22
+ * and its scenarios. A file that does not parse, is outside its module's folder or binds to no functional spec has
23
+ * `error`, and `item` null.
24
+ */
25
+ export interface PlainTestPlainFileNode {
26
+ file: string;
27
+ module: string | null;
28
+ funcId: string | null;
29
+ /** The functional spec's item id, or null. */
30
+ item: string | null;
31
+ conformance: PlainScenario[];
32
+ acceptance: PlainScenario[];
33
+ error?: string;
11
34
  }
12
35
  /** A module one .plain file names, and the file it is - null when it is not found. */
13
36
  export interface PlainLink {
@@ -30,7 +53,7 @@ export interface PlainModuleNode {
30
53
  error?: string;
31
54
  }
32
55
  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. */
56
+ /** An item of a section: a list item, or a functional spec - its heading's line, its body's text. */
34
57
  export interface PlainItemNode {
35
58
  /** `<file>:<line>`. */
36
59
  id: string;
@@ -40,8 +63,14 @@ export interface PlainItemNode {
40
63
  /** The line it starts on, from 1. */
41
64
  line: number;
42
65
  text: string;
43
- /** A functional spec's functionality id: `1`, `2`, ... */
44
- frid?: string;
66
+ /** A functional spec's func-id, its `# <func-id>` heading. */
67
+ funcId?: string;
68
+ /** A functional spec's test plain file, and its scenarios - none when it has no file. */
69
+ testPlainFile?: string | null;
70
+ scenarios?: {
71
+ conformance: PlainScenario[];
72
+ acceptance: PlainScenario[];
73
+ };
45
74
  /** The concepts a definition defines. */
46
75
  defines: string[];
47
76
  /** 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. */
@@ -50,7 +79,7 @@ export interface PlainItemNode {
50
79
  definition: string | null;
51
80
  }>;
52
81
  /**
53
- * The linked resources it names, a functional spec's acceptance tests' too - `[text](target)` as the parser reads
82
+ * The linked resources it names - `[text](target)` as the parser reads
54
83
  * them - with the file each is read from when regenerating, null when it is not there, and the line the link is on.
55
84
  */
56
85
  resources: Array<{
@@ -4,11 +4,15 @@ import { resolveTemplateDirs } from "../regenerate/paths.js";
4
4
  import { locateModule } from "../regenerate/requires.js";
5
5
  import { resourceFile } from "../regenerate/resources.js";
6
6
  import { DEFAULT_CONCEPT_DEFINITIONS, DEFAULT_CONCEPTS, definitionText, extractConceptsFromDefinition, findConcepts } from "./concepts.js";
7
- import { lineNumber, renderToMarkdown } from "./markdown.js";
7
+ import { lineNumber, renderToMarkdown, renderTokens } from "./markdown.js";
8
8
  import { attempt, namesIn, readModule } from "./module-concepts.js";
9
- import { IMPORT_DIRECTIVE, linksIn, modulePlainFile, parsePlainFile, PLAIN_SOURCE_FILE_EXTENSION, plainFileParser, readPlainSourceMetadata, REQUIRES_DIRECTIVE, withParseCache, } from "./parser.js";
9
+ import { IMPORT_DIRECTIVE, linksIn, modulePlainFile, parsePlainFile, PLAIN_SOURCE_FILE_EXTENSION, plainFileParser, readPlainSourceMetadata, REQUIRES_DIRECTIVE, functionalitiesOf, sectionOf, withParseCache, } from "./parser.js";
10
10
  import { DEFINITIONS, FUNCTIONAL_REQUIREMENTS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS } from "./spec.js";
11
11
  import { isStdlibModule } from "./stdlib.js";
12
+ import { discoverTestPlainFiles, readTestPlainFileText } from "./project.js";
13
+ import { parseTestPlainFileSource } from "./test-plain-file.js";
14
+ import { testDirOf } from "../config.js";
15
+ import { strayTestPlainFiles } from "../test-plain-files.js";
12
16
  const SECTIONS = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS, FUNCTIONAL_REQUIREMENTS];
13
17
  /**
14
18
  * A project's ***plain as a graph: every .plain file in its folder and its `template/` folder, and every module they
@@ -83,29 +87,73 @@ export function plainGraph(projectDir) {
83
87
  }
84
88
  }
85
89
  for (const section of SECTIONS) {
86
- const own = (read.parsed?.plainSource[section]?.children ?? []).filter((item) => !read.parsed.importedFrom.has(item));
87
- own.forEach((item, index) => {
88
- const text = renderToMarkdown(item).trim();
90
+ // An item of a list section is its list item; a functional spec is its `# <func-id>` heading and its body.
91
+ const own = [];
92
+ if (read.parsed !== undefined && section === FUNCTIONAL_REQUIREMENTS) {
93
+ for (const functionality of functionalitiesOf(read.parsed.plainSource)) {
94
+ const at = lineOf({ type: "heading", position: { start: { line: functionality.lineNumber, column: 1 }, end: { line: functionality.lineNumber, column: 1 } } }, node.file, lines, { needle: `# ${functionality.id}` });
95
+ own.push({ nodes: functionality.body, line: at, parsedLine: functionality.lineNumber, text: renderTokens(functionality.body), funcId: functionality.id });
96
+ }
97
+ }
98
+ else {
99
+ for (const item of ((read.parsed && sectionOf(read.parsed.plainSource, section)?.children) ?? []).filter((item) => !read.parsed.importedFrom.has(item))) {
100
+ own.push({ nodes: [item], line: lineOf(item, node.file, lines), parsedLine: lineNumber(item), text: renderToMarkdown(item).trim() });
101
+ }
102
+ }
103
+ for (const { nodes, line, parsedLine, text, funcId } of own) {
89
104
  const defines = section === DEFINITIONS ? extractConceptsFromDefinition(text)[0] : [];
90
105
  const named = [...new Set(findConcepts(text).map((found) => found.concept))].filter((concept) => !defines.includes(concept));
91
- const id = idOf(item, node.file);
92
- const line = lineOf(item, node.file, lines);
106
+ const id = `${node.file}:${line}`;
93
107
  // A link is on the line the parser has it on, moved as Liquid moved its item.
94
- const resources = linksIn(item).map(({ text, target, node: link }) => ({
108
+ const resources = nodes.flatMap((item) => linksIn(item)).map(({ text, target, node: link }) => ({
95
109
  text,
96
110
  target,
97
111
  file: resourceFile(target, dirs),
98
- line: lineOf(link, node.file, lines, { at: lineNumber(link) + line - lineNumber(item), needle: `](${target})`, from: line }),
112
+ line: lineOf(link, node.file, lines, { at: lineNumber(link) + line - parsedLine, needle: `](${target})`, from: line }),
99
113
  }));
100
114
  const uses = named.map((concept) => ({ concept, definition: DEFAULT_CONCEPTS.has(concept) ? null : (visible.get(concept) ?? null) }));
101
115
  for (const use of uses)
102
116
  concepts.get(`${use.concept}\0${use.definition ?? ""}`)?.usedBy.push(id);
103
- items.push({ id, file: node.file, module: node.module, section, line, text, ...(section === FUNCTIONAL_REQUIREMENTS ? { frid: String(index + 1) } : {}), defines, uses, resources });
117
+ items.push({ id, file: node.file, module: node.module, section, line, text, ...(funcId !== undefined ? { funcId } : {}), defines, uses, resources });
104
118
  node.items.push(id);
105
- });
119
+ }
106
120
  }
107
121
  }
108
- return { modules: [...modules.values()], items, concepts: [...concepts.values()] };
122
+ const testPlainFiles = testPlainFileNodes(root, dirs, items, lines);
123
+ return { modules: [...modules.values()], items, concepts: [...concepts.values()], testPlainFiles };
124
+ });
125
+ }
126
+ /**
127
+ * The test plain files under the project's test folder, each parsed for its binding and its scenarios, and linked to the
128
+ * functional spec it binds to - that item given its file and its scenarios. A stray one, one that does not parse, or one
129
+ * that binds to no functional spec has its error.
130
+ */
131
+ function testPlainFileNodes(root, dirs, items, lines) {
132
+ const testDir = testDirOf(join(root, `project${PLAIN_SOURCE_FILE_EXTENSION}`));
133
+ const strays = new Map((attempt(() => strayTestPlainFiles(root)) ?? []).map((stray) => [resolve(root, stray.file), stray.problem]));
134
+ for (const item of items)
135
+ if (item.funcId !== undefined)
136
+ Object.assign(item, { testPlainFile: null, scenarios: { conformance: [], acceptance: [] } });
137
+ return (attempt(() => discoverTestPlainFiles([testDir], root)) ?? []).map(([path, name]) => {
138
+ const node = { file: path, module: null, funcId: null, item: null, conformance: [], acceptance: [] };
139
+ let parsed;
140
+ try {
141
+ parsed = parseTestPlainFileSource(readTestPlainFileText(path, name), name, dirs, {});
142
+ }
143
+ catch (error) {
144
+ return { ...node, error: messageOf(error) };
145
+ }
146
+ const scenarios = (list) => list.map((scenario) => ({ id: scenario.id, text: scenario.text, line: lineOf({ type: "heading", position: { start: { line: scenario.lineNumber, column: 1 }, end: { line: scenario.lineNumber, column: 1 } } }, path, lines, { needle: `# ${scenario.id}` }) }));
147
+ Object.assign(node, { module: parsed.module, funcId: parsed.funcId, conformance: scenarios(parsed.conformanceTests), acceptance: scenarios(parsed.acceptanceTests) });
148
+ const stray = strays.get(path);
149
+ if (stray !== undefined)
150
+ return { ...node, error: stray };
151
+ const bound = items.find((item) => item.module === parsed.module && item.funcId === parsed.funcId && item.section === FUNCTIONAL_REQUIREMENTS);
152
+ if (bound === undefined)
153
+ return { ...node, error: `${name} binds to ${parsed.module}:${parsed.funcId}, and there is no such functional spec` };
154
+ bound.testPlainFile = path;
155
+ bound.scenarios = { conformance: node.conformance, acceptance: node.acceptance };
156
+ return { ...node, item: bound.id };
109
157
  });
110
158
  }
111
159
  /** 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. */
@@ -34,9 +34,10 @@ export declare function highlightLine(line: string, state?: HighlightState): {
34
34
  export declare function highlightLines(lines: string[]): Token[][];
35
35
  /**
36
36
  * What a piece of ***plain is, for its colour: a section's header, a concept, a link, text in quotes or
37
- * backticks, a front matter key, a module a front matter names, a comment, a list's bullet.
37
+ * backticks, a front matter key, a module a front matter names, a comment, a list's bullet, the `# <id>` heading of a
38
+ * functional spec or a scenario.
38
39
  */
39
- export type PlainRole = "section" | "concept" | "link" | "literal" | "keyword" | "module" | "comment" | "bullet";
40
+ export type PlainRole = "section" | "concept" | "link" | "literal" | "keyword" | "module" | "comment" | "bullet" | "id";
40
41
  /** A token's role: its innermost scope that has one. */
41
42
  export declare function roleOf(scopes: string[]): PlainRole | undefined;
42
43
  export {};
@@ -7,7 +7,7 @@ function readGrammar(raw) {
7
7
  const read = (rules, into, seen) => {
8
8
  for (const rule of rules) {
9
9
  if (rule.include !== undefined) {
10
- // Only the repository's: plyn's grammar includes nothing else, and a rule that includes itself is followed once.
10
+ // Only the repository's: the grammar includes nothing else, and a rule that includes itself is followed once.
11
11
  if (!rule.include.startsWith("#"))
12
12
  continue;
13
13
  const key = rule.include.slice(1);
@@ -53,7 +53,7 @@ function readGrammar(raw) {
53
53
  return root;
54
54
  }
55
55
  let grammar;
56
- /** plyn's grammar, read once - or null when it cannot be. */
56
+ /** The grammar, read once - or null when it cannot be. */
57
57
  function plainGrammar() {
58
58
  if (grammar !== undefined)
59
59
  return grammar;
@@ -175,12 +175,13 @@ const ROLES = [
175
175
  ["comment", "comment"],
176
176
  ["keyword", "keyword"],
177
177
  ["entity.name.function", "section"],
178
+ ["entity.name.tag.id", "id"],
178
179
  ["entity.name.type", "module"],
179
180
  ["entity.name.module", "module"],
180
181
  ["entity.punctuation", "bullet"],
181
182
  ["punctuation", "bullet"],
182
183
  ["variable", "concept"],
183
- // A link is plyn's `string.quoted.other.plain`; its quotes are `string.quoted.double`, `...other.backtick` and the like.
184
+ // A link is the grammar's `string.quoted.other.plain`; its quotes are `string.quoted.double`, `...other.backtick` and the like.
184
185
  ["string.quoted.other.plain", "link"],
185
186
  ["string", "literal"],
186
187
  ];
@@ -1,15 +1,18 @@
1
1
  /**
2
- * The ***plain*** parser: a TypeScript port of the reference implementation.
2
+ * The ***plain parser: a TypeScript port of the reference implementation.
3
3
  *
4
- * Mirrors plain_file.py, plain_spec.py, concept_utils.py and the file_utils.py
5
- * helpers they depend on. The entry points are `plainFileParser` (a .plain file to a
6
- * marshalled tree) and `iterateFunctionalSpecs` (the tree to one rendering context per
7
- * functionality).
4
+ * Mirrors plain_file.py, plain_spec.py, concept_utils.py, graph.py, test_plain_file.py, project.py and the
5
+ * loaders.py helpers they depend on. The entry points are `plainFileParser` (a .plain file to a marshalled tree,
6
+ * with its test plain files bound when it is given its project's test folder), `parseProject` (the reference's,
7
+ * test folders given) and `iterateFunctionalSpecs` (the tree to one rendering context per functional spec).
8
8
  */
9
9
  export * from "./errors.ts";
10
10
  export * from "./markdown.ts";
11
11
  export * from "./concepts.ts";
12
+ export * from "./cycles.ts";
12
13
  export * from "./files.ts";
13
14
  export * from "./liquid.ts";
14
15
  export * from "./parser.ts";
16
+ export * from "./project.ts";
15
17
  export * from "./spec.ts";
18
+ export * from "./test-plain-file.ts";
@@ -1,15 +1,18 @@
1
1
  /**
2
- * The ***plain*** parser: a TypeScript port of the reference implementation.
2
+ * The ***plain parser: a TypeScript port of the reference implementation.
3
3
  *
4
- * Mirrors plain_file.py, plain_spec.py, concept_utils.py and the file_utils.py
5
- * helpers they depend on. The entry points are `plainFileParser` (a .plain file to a
6
- * marshalled tree) and `iterateFunctionalSpecs` (the tree to one rendering context per
7
- * functionality).
4
+ * Mirrors plain_file.py, plain_spec.py, concept_utils.py, graph.py, test_plain_file.py, project.py and the
5
+ * loaders.py helpers they depend on. The entry points are `plainFileParser` (a .plain file to a marshalled tree,
6
+ * with its test plain files bound when it is given its project's test folder), `parseProject` (the reference's,
7
+ * test folders given) and `iterateFunctionalSpecs` (the tree to one rendering context per functional spec).
8
8
  */
9
9
  export * from "./errors.js";
10
10
  export * from "./markdown.js";
11
11
  export * from "./concepts.js";
12
+ export * from "./cycles.js";
12
13
  export * from "./files.js";
13
14
  export * from "./liquid.js";
14
15
  export * from "./parser.js";
16
+ export * from "./project.js";
15
17
  export * from "./spec.js";
18
+ export * from "./test-plain-file.js";
@@ -63,12 +63,7 @@ export function createEnvironment(options) {
63
63
  }
64
64
  const templateSource = options.loadTemplateSource(name);
65
65
  if (templateSource === null) {
66
- throw new Error(`Template not found: ${name}\n` +
67
- "The required template could not be found. Templates are searched in the following order " +
68
- "(highest to lowest precedence):\n\n" +
69
- " 1. The directory containing your .plain file\n" +
70
- " 2. The directory specified by --template-dir (if provided)\n" +
71
- " 3. The built-in 'standard_template_library' directory\n");
66
+ throw new Error(`Template not found: ${name}`);
72
67
  }
73
68
  // The reference implementation's loader: rstrip, then re-indent every line to match the
74
69
  // include site. The rstrip is what stops a block-level include from ending