@lingfan-engine/stories-build 0.0.0-stage → 0.1.2

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 (110) hide show
  1. package/LICENSE +201 -0
  2. package/bin/stories-build.mjs +16 -0
  3. package/bin/stories-watch.mjs +16 -0
  4. package/package.json +50 -3
  5. package/src/authoring.d.ts +51 -0
  6. package/src/cell/errors.ts +12 -0
  7. package/src/cell/imports.ts +127 -0
  8. package/src/cell/index.ts +33 -0
  9. package/src/cell/render-register.ts +33 -0
  10. package/src/cell/scan-cells.ts +63 -0
  11. package/src/cell/scan-file.ts +108 -0
  12. package/src/cell/types.ts +27 -0
  13. package/src/cell/whitelist.ts +22 -0
  14. package/src/cli/cli.ts +99 -0
  15. package/src/cli/watch-cli.ts +42 -0
  16. package/src/compiler/cell-scan.ts +27 -0
  17. package/src/compiler/compiler-contract.ts +87 -0
  18. package/src/compiler/extension-load.ts +67 -0
  19. package/src/compiler/index.ts +22 -0
  20. package/src/compiler/story-compiler/assemble/functions.ts +202 -0
  21. package/src/compiler/story-compiler/assemble/index.ts +8 -0
  22. package/src/compiler/story-compiler/assemble/session.ts +207 -0
  23. package/src/compiler/story-compiler/assemble/types.ts +37 -0
  24. package/src/compiler/story-compiler/assemble.ts +5 -0
  25. package/src/compiler/story-compiler/graph/build.ts +208 -0
  26. package/src/compiler/story-compiler/graph/core.ts +102 -0
  27. package/src/compiler/story-compiler/graph/fold.ts +121 -0
  28. package/src/compiler/story-compiler/graph/index.ts +17 -0
  29. package/src/compiler/story-compiler/graph/scan/bindings.ts +277 -0
  30. package/src/compiler/story-compiler/graph/scan/imports.ts +170 -0
  31. package/src/compiler/story-compiler/graph/scan/index.ts +9 -0
  32. package/src/compiler/story-compiler/graph/scan/module-scan.ts +199 -0
  33. package/src/compiler/story-compiler/graph/state-root.ts +147 -0
  34. package/src/compiler/story-compiler/graph.ts +18 -0
  35. package/src/compiler/story-compiler/index.ts +34 -0
  36. package/src/compiler/story-compiler/translate/expr/assign.ts +204 -0
  37. package/src/compiler/story-compiler/translate/expr/engine-text.ts +16 -0
  38. package/src/compiler/story-compiler/translate/expr/expr.ts +174 -0
  39. package/src/compiler/story-compiler/translate/expr/index.ts +24 -0
  40. package/src/compiler/story-compiler/translate/expr/lower/index.ts +4 -0
  41. package/src/compiler/story-compiler/translate/expr/lower/lower.ts +266 -0
  42. package/src/compiler/story-compiler/translate/expr/lower/numeric.ts +56 -0
  43. package/src/compiler/story-compiler/translate/expr/probe.ts +23 -0
  44. package/src/compiler/story-compiler/translate/expr/state-path.ts +47 -0
  45. package/src/compiler/story-compiler/translate/expr/words/arg.ts +87 -0
  46. package/src/compiler/story-compiler/translate/expr/words/call.ts +125 -0
  47. package/src/compiler/story-compiler/translate/expr/words/callee.ts +43 -0
  48. package/src/compiler/story-compiler/translate/expr/words/command.ts +93 -0
  49. package/src/compiler/story-compiler/translate/expr/words/dispatch.ts +16 -0
  50. package/src/compiler/story-compiler/translate/expr/words/index.ts +8 -0
  51. package/src/compiler/story-compiler/translate/expr/words/static-for.ts +96 -0
  52. package/src/compiler/story-compiler/translate/font-check.ts +94 -0
  53. package/src/compiler/story-compiler/translate/index.ts +10 -0
  54. package/src/compiler/story-compiler/translate/shared.ts +132 -0
  55. package/src/compiler/story-compiler/translate/stmt/control/branch.ts +71 -0
  56. package/src/compiler/story-compiler/translate/stmt/control/index.ts +11 -0
  57. package/src/compiler/story-compiler/translate/stmt/control/iter.ts +72 -0
  58. package/src/compiler/story-compiler/translate/stmt/control/loop.ts +114 -0
  59. package/src/compiler/story-compiler/translate/stmt/control/switch.ts +75 -0
  60. package/src/compiler/story-compiler/translate/stmt/control-flow.ts +25 -0
  61. package/src/compiler/story-compiler/translate/stmt/index.ts +17 -0
  62. package/src/compiler/story-compiler/translate/stmt/statements.ts +145 -0
  63. package/src/compiler/story-compiler/translate.ts +15 -0
  64. package/src/compiler/story-compiler.ts +119 -0
  65. package/src/config/index.ts +15 -0
  66. package/src/config/manifest-read.ts +26 -0
  67. package/src/config/state-type.ts +85 -0
  68. package/src/config/story-config.ts +113 -0
  69. package/src/const-eval/config-eval.ts +181 -0
  70. package/src/const-eval/const-eval-pass.ts +133 -0
  71. package/src/const-eval/const-eval.ts +114 -0
  72. package/src/const-eval/const-expr.ts +358 -0
  73. package/src/const-eval/contracts.ts +31 -0
  74. package/src/const-eval/define-merge.ts +195 -0
  75. package/src/const-eval/diag/compare.ts +39 -0
  76. package/src/const-eval/diag/index.ts +5 -0
  77. package/src/const-eval/diag/locate.ts +45 -0
  78. package/src/const-eval/index.ts +44 -0
  79. package/src/const-eval/module-characters.ts +353 -0
  80. package/src/const-eval/module-define.ts +300 -0
  81. package/src/const-eval/round/evaluate.ts +149 -0
  82. package/src/const-eval/round/index.ts +4 -0
  83. package/src/const-eval/round/source.ts +134 -0
  84. package/src/const-eval/state-schema.ts +269 -0
  85. package/src/discovery/index.ts +7 -0
  86. package/src/discovery/source-discovery.ts +193 -0
  87. package/src/emit/compile-roundtrip.ts +45 -0
  88. package/src/emit/emit.ts +147 -0
  89. package/src/emit/index.ts +9 -0
  90. package/src/emit/report.ts +29 -0
  91. package/src/fonts/font-registry.ts +53 -0
  92. package/src/fonts/index.ts +7 -0
  93. package/src/index.ts +76 -0
  94. package/src/pipeline/gates.ts +107 -0
  95. package/src/pipeline/index.ts +18 -0
  96. package/src/pipeline/orchestrate.ts +459 -0
  97. package/src/pipeline/watch.ts +244 -0
  98. package/src/shared/errors.ts +2 -0
  99. package/src/shared/index.ts +11 -0
  100. package/src/shared/texts.ts +85 -0
  101. package/src/shared/walk-files.ts +18 -0
  102. package/src/typegen/collect.ts +107 -0
  103. package/src/typegen/font-render.ts +30 -0
  104. package/src/typegen/index.ts +18 -0
  105. package/src/typegen/render.ts +68 -0
  106. package/src/typegen/script-render.ts +21 -0
  107. package/src/typegen/state-enum-ast.ts +365 -0
  108. package/src/typegen/state-render.ts +242 -0
  109. package/src/typegen/types.ts +21 -0
  110. package/README.md +0 -3
@@ -0,0 +1,244 @@
1
+ /**
2
+ * 创作期增量监听(`pnpm stories:watch`):常驻进程内监听源目录变更,防抖合并后
3
+ * 重跑 `buildStories`。编译始终全量——不引入第二套编译规则,任何一轮的产物都与
4
+ * `stories:build` 一致;差量落盘由 `emitArtifacts` 完成。
5
+ *
6
+ * `affectedStories` 提供改动的波及面报告(反向依赖闭包),仅用于日志呈现,
7
+ * 不参与编译。
8
+ */
9
+ import { watch } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import { DEFAULT_SOURCES_DIR, buildStories } from "./index.ts";
12
+ import type { StoryBuildOptions } from "./orchestrate.ts";
13
+ import { walkFiles } from "../shared/index.ts";
14
+
15
+ /** 相对导入抽取(单双引号皆可;type 导入同算依赖——保守向) */
16
+ const REL_IMPORT_RE = /(?:^|[\s;])from\s+["'](\.{1,2}\/[^"']+)["']/g;
17
+
18
+ /**
19
+ * 从一段 TS 源文本抽取相对导入说明符(去扩展名与 /index 后缀的规范化模块路径,
20
+ * 与图谱层的模块键同形)。
21
+ */
22
+ function extractRelImports(text: string): string[] {
23
+ const specs: string[] = [];
24
+ for (const match of text.matchAll(REL_IMPORT_RE)) {
25
+ specs.push(match[1]!);
26
+ }
27
+ return specs;
28
+ }
29
+
30
+ /** 规范化相对说明符 → 模块键(`./a/b` → `a/b`、`../lib/x.ts` → `lib/x`、`./x/index` → `x`) */
31
+ function normalizeSpec(spec: string, fromDir: string): string {
32
+ const parts = `${fromDir}/${spec}`.split("/");
33
+ const out: string[] = [];
34
+ for (const part of parts) {
35
+ if (part === "." || part === "") continue;
36
+ if (part === "..") out.pop();
37
+ else out.push(part);
38
+ }
39
+ let key = out.join("/");
40
+ key = key.replace(/\.ts$/, "").replace(/\/index$/, "");
41
+ return key;
42
+ }
43
+
44
+ /**
45
+ * 反向依赖闭包:给「改动文件集合」求「受影响的故事文件集合」。
46
+ * 输入 = 全部源文件文本(相对工程根 posix 路径 → 文本,含 Stories.src/config.ts);
47
+ * 闭包沿「谁导入了我」逐层展开,只有命中故事文件(default 导出列)才算受影响列。
48
+ */
49
+ export function affectedStories(
50
+ changed: readonly string[],
51
+ sourceTexts: ReadonlyMap<string, string>,
52
+ ): string[] {
53
+ // 模块键 → 文件 posix 相对路径(config.ts 与故事/模块文件都进索引)
54
+ const byModuleKey = new Map<string, string>();
55
+ for (const rel of sourceTexts.keys()) {
56
+ const normalized = rel.replace(/\.ts$/, "").replace(/\/index$/, "");
57
+ byModuleKey.set(normalized, rel);
58
+ }
59
+ // 反向边:模块键 → 直接引用它的文件集合
60
+ const importers = new Map<string, Set<string>>();
61
+ for (const [rel, text] of sourceTexts) {
62
+ const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
63
+ for (const spec of extractRelImports(text)) {
64
+ const key = normalizeSpec(spec, dir);
65
+ const target = byModuleKey.get(key);
66
+ if (target === undefined) continue;
67
+ const set = importers.get(target);
68
+ if (set === undefined) importers.set(target, new Set([rel]));
69
+ else set.add(rel);
70
+ }
71
+ }
72
+ // 故事文件判据:default export function(与 story-compiler 的故事形态对齐)
73
+ const storyOf = new Map<string, string>();
74
+ for (const [rel, text] of sourceTexts) {
75
+ if (/export\s+default\s+function\s+\w/.test(text)) storyOf.set(rel, rel);
76
+ }
77
+ const affected = new Set<string>();
78
+ const queue = [...changed];
79
+ while (queue.length > 0) {
80
+ const rel = queue.pop()!;
81
+ if (affected.has(rel)) continue;
82
+ affected.add(rel);
83
+ for (const importer of importers.get(rel) ?? []) {
84
+ queue.push(importer);
85
+ }
86
+ }
87
+ return [...affected].filter((rel) => storyOf.has(rel)).sort();
88
+ }
89
+
90
+ /** `watchStories` 的可配置项:在 `StoryBuildOptions` 之上只加监听节奏两旋钮。 */
91
+ export interface WatchStoriesOptions extends StoryBuildOptions {
92
+ /** 变更后合并再编译的等待毫秒数(缺省 150) */
93
+ readonly debounceMs?: number;
94
+ /** 轮间打印回调(缺省 console.log;可注入收集器静默) */
95
+ readonly log?: (message: string) => void;
96
+ }
97
+
98
+ /** 一次变更轮的结果:产物差量 + 受影响故事集 */
99
+ export interface WatchRoundReport {
100
+ /** 本轮产物差量(相对上一轮落盘状态;语义同 `BuildReport.added/updated/removed`) */
101
+ added: string[];
102
+ updated: string[];
103
+ removed: string[];
104
+ /** 本轮改动波及的故事源(反向依赖闭包,仅呈现) */
105
+ affectedStories: string[];
106
+ /** 本轮是否编译成功(失败时 added/updated/removed 为空数组) */
107
+ ok: boolean;
108
+ }
109
+
110
+ /** 源树与字体目录的监听目标(相对工程根) */
111
+ const WATCHED_FONT_DIR = join("Resources", "Fonts");
112
+
113
+ /**
114
+ * 常驻增量监听入口:首轮全量构建落基准 → `fs.watch` 监听源目录(与字体目录,
115
+ * 存在才挂)→ 变更防抖后进程内重跑 `buildStories` 并把差量与受影响故事集交给
116
+ * 回调/日志。返回停止函数(关 watcher、落当前任务);失败轮不终止监听——
117
+ * 下一次改动照常重试(创作期半成品常态)。
118
+ */
119
+ export async function watchStories(
120
+ root: string,
121
+ options: WatchStoriesOptions = {},
122
+ ): Promise<() => void> {
123
+ const log = options.log ?? ((message: string) => console.log(message));
124
+ const debounceMs = options.debounceMs ?? 150;
125
+ const sourcesDir = resolve(root, options.sourcesDir ?? DEFAULT_SOURCES_DIR);
126
+ const fontsDir = resolve(root, WATCHED_FONT_DIR);
127
+
128
+ // 首轮:全量构建落基准
129
+ try {
130
+ const report = await buildStories(root, options);
131
+ log(firstRoundLine(report));
132
+ } catch (error) {
133
+ log(`[stories:watch] 首轮构建失败:${errorText(error)}`);
134
+ log("[stories:watch] 监听中——修复后保存即重试。");
135
+ }
136
+
137
+ let timer: ReturnType<typeof setTimeout> | undefined;
138
+ let running = false;
139
+ let rerun = false;
140
+ // 变更文件集合(防抖窗口内累计;"rename"/目录级事件给不准路径时退化为全源扫描)
141
+ let pending = new Set<string>();
142
+
143
+ const runRound = async (): Promise<void> => {
144
+ if (running) {
145
+ rerun = true;
146
+ return;
147
+ }
148
+ running = true;
149
+ const changed = [...pending];
150
+ pending = new Set();
151
+ try {
152
+ const sourceTexts = readSourceTexts(root, options);
153
+ const affected = affectedStories(changed, sourceTexts);
154
+ const report = await buildStories(root, options);
155
+ log(roundLine(report, affected));
156
+ } catch (error) {
157
+ log(`[stories:watch] 本轮构建失败(监听继续):${errorText(error)}`);
158
+ } finally {
159
+ running = false;
160
+ if (rerun) {
161
+ rerun = false;
162
+ void runRound();
163
+ }
164
+ }
165
+ };
166
+
167
+ const schedule = (relPath: string | undefined): void => {
168
+ if (relPath !== undefined && relPath !== "") pending.add(relPath);
169
+ if (timer !== undefined) clearTimeout(timer);
170
+ timer = setTimeout(() => {
171
+ timer = undefined;
172
+ void runRound();
173
+ }, debounceMs);
174
+ };
175
+
176
+ const watchers: ReturnType<typeof watch>[] = [];
177
+ // filename 相对监听目录;prefix 归一成与 readSourceTexts 键同型的工程相对路径
178
+ const mount = (dir: string, prefix: string): void => {
179
+ try {
180
+ const w = watch(dir, { recursive: true }, (_event, filename) => {
181
+ // filename 为 null = 平台给不准路径,退化记录哨兵(全源扫描)
182
+ const rel = filename === null ? undefined : String(filename);
183
+ schedule(
184
+ rel === undefined ? undefined : `${prefix}${rel.replace(/\\/g, "/")}`,
185
+ );
186
+ });
187
+ watchers.push(w);
188
+ } catch {
189
+ // 目录不存在(如 Fonts 未建)——跳过,不阻塞监听主目标
190
+ }
191
+ };
192
+ mount(sourcesDir, `${options.sourcesDir ?? DEFAULT_SOURCES_DIR}/`);
193
+ mount(fontsDir, `${WATCHED_FONT_DIR}/`);
194
+
195
+ return () => {
196
+ for (const w of watchers) w.close();
197
+ if (timer !== undefined) clearTimeout(timer);
198
+ };
199
+ }
200
+
201
+ /** 读取源文本快照:源树全部 .ts 文本(跳生成物目录),posix 相对路径 → 文本 */
202
+ function readSourceTexts(
203
+ root: string,
204
+ options: StoryBuildOptions,
205
+ ): Map<string, string> {
206
+ const sourcesDirName = options.sourcesDir ?? DEFAULT_SOURCES_DIR;
207
+ const sourcesDir = resolve(root, sourcesDirName);
208
+ const texts = new Map<string, string>();
209
+ for (const [rel, text] of walkFiles(sourcesDir)) {
210
+ const posix = rel.replace(/\\/g, "/");
211
+ if (posix.startsWith("gen/") || !posix.endsWith(".ts")) continue;
212
+ texts.set(`${sourcesDirName}/${posix}`, text);
213
+ }
214
+ return texts;
215
+ }
216
+
217
+ function firstRoundLine(report: { storyId: string; columns: number }): string {
218
+ return `首轮构建完成:story "${report.storyId}"(${report.columns} 列),监听中——保存即重编译。`;
219
+ }
220
+
221
+ function roundLine(
222
+ report: { added: string[]; updated: string[]; removed: string[] },
223
+ affected: readonly string[],
224
+ ): string {
225
+ const parts: string[] = [];
226
+ const total =
227
+ report.added.length + report.updated.length + report.removed.length;
228
+ if (total === 0) {
229
+ parts.push("产物零差量");
230
+ } else {
231
+ if (report.added.length > 0) parts.push(`新增 ${report.added.length}`);
232
+ if (report.updated.length > 0) parts.push(`更新 ${report.updated.length}`);
233
+ if (report.removed.length > 0) parts.push(`清理 ${report.removed.length}`);
234
+ }
235
+ const affectedLabel =
236
+ affected.length === 0
237
+ ? "—"
238
+ : affected.map((rel) => rel.replace(/\.ts$/, "")).join("、");
239
+ return `监听轮完成(${parts.join(",")});波及故事:${affectedLabel}`;
240
+ }
241
+
242
+ function errorText(error: unknown): string {
243
+ return error instanceof Error ? error.message : String(error);
244
+ }
@@ -0,0 +1,2 @@
1
+ /** 构建失败(fail-closed)。消息即 CLI 打印的失败原因,逐字稳定——不要在别处重写措辞 */
2
+ export class StoryBuildError extends Error {}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 通用出口:失败类型、文件遍历与集中文案表。
3
+ *
4
+ * 这三样跨域用得多——`StoryBuildError` 被十来个文件当统一失败口径,
5
+ * `walkFiles` 被发现、写盘、编译器三处共同依赖,`StoriesBuildTexts` 是
6
+ * 构建期全部可注入文案的唯一出处(缺省实现 = 现场字符串)——放进任何
7
+ * 业务域都会让别的域为了一个工具反向依赖它,故单独成域。
8
+ */
9
+ export { StoryBuildError } from "./errors.ts";
10
+ export { walkFiles } from "./walk-files.ts";
11
+ export { defaultStoriesBuildTexts, type StoriesBuildTexts } from "./texts.ts";
@@ -0,0 +1,85 @@
1
+ /**
2
+ * 集中文案表:stories-build 全部可注入的构建失败/诊断文案。
3
+ *
4
+ * 形态照 engine 的 `contracts/messages.ts`:具名方法接口 + 缺省实现对象;
5
+ * 缺省实现即构建层的既有文案,不传 texts 行为不变。
6
+ * 注入走编译入口 options(`StoryBuildOptions.texts` / `compileStories`
7
+ * input.texts,均接受 Partial),入口处
8
+ * `{ ...defaultStoriesBuildTexts, ...texts }` 合并后作为完整对象逐层透传——
9
+ * 纯函数管道,无模块级可变状态、无注册表。
10
+ *
11
+ * 错误码前缀(E06/E10 等)是协议值,保留在文案内:自定义文案须自行保留
12
+ * 前缀,否则按码检索的诊断路径(测试/工具)会失配。
13
+ */
14
+ export interface StoriesBuildTexts {
15
+ /** E06:列 id 为空或含不安全字符(story-compiler checkColumnIds) */
16
+ columnIdUnsafe(id: string): string;
17
+ /** E06:列 id 与先前列 id 重复(大小写不敏感;story-compiler checkColumnIds) */
18
+ columnIdDuplicate(id: string, prev: string): string;
19
+ /** E10:列体顶层 return(translate/statements) */
20
+ topLevelReturn(): string;
21
+ /** E10:break 出现在循环/switch 之外(translate/statements) */
22
+ breakOutsideLoop(): string;
23
+ /** E10:continue 出现在循环之外(translate/statements) */
24
+ continueOutsideLoop(): string;
25
+ /** unknown-op 闸门长文(pipeline/gates;detail = 已折叠的 op 明细行集) */
26
+ unknownOpsGate(
27
+ detail: string,
28
+ resourcesDir: string,
29
+ manifestFile: string,
30
+ ): string;
31
+ /** guard-unknown 闸门长文(pipeline/gates;firstGuard = 首个未声明守卫名) */
32
+ unknownGuardsGate(
33
+ detail: string,
34
+ sourcesDir: string,
35
+ firstGuard: string,
36
+ ): string;
37
+ /** 覆盖外 default 导出拒绝长文(discovery;files = 覆盖外文件相对路径列表) */
38
+ uncoveredDefaults(files: readonly string[], sourcesDirName: string): string;
39
+ /** 故事文件 node_modules 值导入的失败行(const-eval-pass;含 `dir/rel:line:col` 前缀) */
40
+ storyNpmValueImport(
41
+ rel: string,
42
+ line: number,
43
+ col: number,
44
+ spec: string,
45
+ sourcesDirName: string,
46
+ ): string;
47
+ /** 模块层 const 引用了 node_modules 值导入(graph/bindings) */
48
+ constNpmReference(): string;
49
+ /** node_modules 值导入被函数体/其他语句引用(graph/bindings) */
50
+ npmNameConstOnly(name: string): string;
51
+ /** config.ts 缺失(const-eval/config-eval) */
52
+ configMissing(sourcesDirName: string, configFile: string): string;
53
+ }
54
+
55
+ /** 缺省文案:即 stories-build 的既有文案。 */
56
+ export const defaultStoriesBuildTexts: StoriesBuildTexts = {
57
+ columnIdUnsafe: (id) => `列 id \`${id}\` 为空或含不安全字符(E06)`,
58
+ columnIdDuplicate: (id, prev) =>
59
+ `列 id \`${id}\` 与 ${prev} 重复(大小写不敏感,E06)`,
60
+ topLevelReturn: () => "列体顶层不能 return(E10):return 只能在函数体内",
61
+ breakOutsideLoop: () => "break 在循环/switch 之外(E10)",
62
+ continueOutsideLoop: () => "continue 在循环之外(E10)",
63
+ unknownOpsGate: (detail, resourcesDir, manifestFile) =>
64
+ `故事使用了既非内建、也未被清单 extensions 声明扩展提供的 op(运行期必 unknown-op,构建期拦截):\n${detail}\n` +
65
+ `→ 在 ${resourcesDir}/${manifestFile} 的 extensions 声明提供该 op 的扩展模块,或改用内建 op`,
66
+ unknownGuardsGate: (detail, sourcesDir, firstGuard) =>
67
+ `故事使用了未在源内 cell(...) 声明的守卫(运行期必 guard-unknown,构建期拦截):\n${detail}\n` +
68
+ `→ 在 ${sourcesDir} 源里用 cell("${firstGuard}", impl) 声明实现,或改用已声明的守卫`,
69
+ uncoveredDefaults: (files, sourcesDirName) =>
70
+ [
71
+ "以下文件含 default 导出但未被 folders 覆盖——Stories.src 全量管理构建产物,未登记的故事会在构建时被当作陈旧产物删除:",
72
+ ...files.map((rel) => `- ${sourcesDirName}/${rel}`),
73
+ '请把所在目录加入 config.ts 的 folders(或使用 "*" 兜底)。',
74
+ ].join("\n"),
75
+ storyNpmValueImport: (rel, line, col, spec, sourcesDirName) =>
76
+ `- ${sourcesDirName}/${rel}:${line}:${col} 故事文件不支持 node_modules 值导入 \`${spec}\`——npm 值请收敛为纯模块的导出 const(编译期求值仅作用于纯模块),再经相对导入使用(编译期求值引用域规则)`,
77
+ constNpmReference: () =>
78
+ "模块层 const 引用了 node_modules 值导入——仅导出的 const 可经编译期求值内联(请把 npm 值收敛为导出 const,见编译期求值规则)",
79
+ npmNameConstOnly: (name) =>
80
+ `node_modules 值导入 \`${name}\` 仅限模块层 const 初始化链(编译期求值引用域规则):函数体与其他语句不得引用`,
81
+ configMissing: (sourcesDirName, configFile) =>
82
+ `${sourcesDirName}/${configFile} 不存在——多目录 TS 源工程需要布局声明` +
83
+ `(export const folders/start/vars/state):folders 声明源目录(可含 "*" 全量兜底),` +
84
+ `start 声明入口故事文件名`,
85
+ };
@@ -0,0 +1,18 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /** 收集目录下全部文件(posix 相对路径 → 文本);目录不存在 = 空集 */
5
+ export function walkFiles(dir: string, rel = ""): Map<string, string> {
6
+ const out = new Map<string, string>();
7
+ if (!existsSync(dir)) return out;
8
+ for (const name of readdirSync(dir).sort()) {
9
+ const path = join(dir, name);
10
+ const key = rel === "" ? name : `${rel}/${name}`;
11
+ if (statSync(path).isDirectory()) {
12
+ for (const [child, text] of walkFiles(path, key)) out.set(child, text);
13
+ } else {
14
+ out.set(key, readFileSync(path, "utf8"));
15
+ }
16
+ }
17
+ return out;
18
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * 声明名收集:从产物 op 流里把「作者声明过的名字」捞出来。
3
+ *
4
+ * 这些名字是运行期变量表的内容(`{ticket}`、`{thing}` 这类插值键),既不在 stateSchema
5
+ * 里也不在 defineVars 清单里,只能从编译产物读——这正是「插值键校验漏报」最容易漏的一类。
6
+ * 字段名是引擎 op 契约,逐类照着 op 定义写死;出现新 op 时在此补一行即可。
7
+ */
8
+
9
+ /** 声明名所在的字段:op → 该 op 里承载名字的字段名 */
10
+ const DECL_NAME_FIELDS: Readonly<Record<string, string>> = {
11
+ set: "key",
12
+ define: "key",
13
+ let: "key",
14
+ local: "key",
15
+ undef: "key",
16
+ array: "key",
17
+ array_push: "key",
18
+ array_pop: "key",
19
+ dict: "key",
20
+ dict_set: "key",
21
+ // 结果绑定名:语句级 `const r = f(1)` 降糖成 call op 后唯一携带声明名的字段——
22
+ // 引擎把 into 无条件声明成调用方局部变量(无带值 return 时绑 null)。
23
+ call: "into",
24
+ for: "var",
25
+ foreach: "var",
26
+ random: "var",
27
+ input: "store",
28
+ minigame: "game",
29
+ };
30
+
31
+ /** op 名 → 名字取值的键;无映射返回 undefined */
32
+ function declaredFieldOf(op: string): string | undefined {
33
+ return DECL_NAME_FIELDS[op];
34
+ }
35
+
36
+ function isRecord(value: unknown): value is Record<string, unknown> {
37
+ return typeof value === "object" && value !== null && !Array.isArray(value);
38
+ }
39
+
40
+ /** 读一个 op 的声明名;字段缺失或非字符串都忽略(产物形态异常不在本模块职责内) */
41
+ function readDeclaredName(op: unknown): string | undefined {
42
+ if (!isRecord(op)) return undefined;
43
+ const opName = op.op;
44
+ if (typeof opName !== "string") return undefined;
45
+ const field = declaredFieldOf(opName);
46
+ if (field === undefined) return undefined;
47
+ const value = op[field];
48
+ return typeof value === "string" && value !== "" ? value : undefined;
49
+ }
50
+
51
+ /**
52
+ * 递归收集一段命令流里的声明名:
53
+ * 控制流(if/while/for/switch/when)把名字藏在 body/then/else/cases 里,
54
+ * `func` 自带 body、`call` 带 args 与 into 绑定名,都要照走;
55
+ * `minigame.reward[].key` 是奖励发到哪个变量。
56
+ */
57
+ function collectFromCommands(
58
+ commands: readonly unknown[],
59
+ into: Set<string>,
60
+ ): void {
61
+ for (const command of commands) {
62
+ if (!isRecord(command)) continue;
63
+
64
+ const declared = readDeclaredName(command);
65
+ if (declared !== undefined) into.add(declared);
66
+
67
+ // 容器字段:值为命令数组的(body/then/else 等)
68
+ for (const [field, value] of Object.entries(command)) {
69
+ if (!Array.isArray(value)) continue;
70
+ if (field === "options" || field === "cases" || field === "reward")
71
+ continue;
72
+ collectFromCommands(value, into);
73
+ }
74
+
75
+ // cases:[{ when: string; body: Command[] }]
76
+ if (Array.isArray(command.cases)) {
77
+ for (const item of command.cases) {
78
+ if (!isRecord(item)) continue;
79
+ if (Array.isArray(item.body)) collectFromCommands(item.body, into);
80
+ }
81
+ }
82
+
83
+ // reward:[{ key: string; amount: number }]——奖励发到哪个变量也属插值键域
84
+ if (Array.isArray(command.reward)) {
85
+ for (const item of command.reward) {
86
+ if (!isRecord(item)) continue;
87
+ if (typeof item.key === "string" && item.key !== "") into.add(item.key);
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 从编译产物的列集合里收集所有声明名。
95
+ * 排序去重后返回——同输入同输出是渲染器零随机的前提。
96
+ */
97
+ export function collectDeclaredNames(
98
+ columns: readonly { readonly commands?: readonly unknown[] }[],
99
+ ): string[] {
100
+ const names = new Set<string>();
101
+ for (const column of columns) {
102
+ const commands = column.commands;
103
+ if (!Array.isArray(commands)) continue;
104
+ collectFromCommands(commands, names);
105
+ }
106
+ return [...names].sort();
107
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * 字体名注册表的 ambient 类型面渲染器(`font` 全局常量字面量类型)。
3
+ *
4
+ * 输出 = `Stories.src/gen/font.gen.d.ts` 全文:`declare const font` 的键与值都取
5
+ * 注册表字体名(引号键保非标识符名可用),未注册名在 TS 面即不存在——IDE 补全只给
6
+ * 正名,改拼直接 TS2551。纯编译期声明,不注入运行时对象;模块自有 `font` 声明按
7
+ * TS 遮蔽语义优先。空注册表产出 `Record<never, never>`:TS 里空对象字面量 `{}` 会
8
+ * 放开任意键访问,等于放弃改拼保护,这里必须给封闭空类型。
9
+ */
10
+
11
+ import type { FontEntry } from "../fonts/font-registry.ts";
12
+
13
+ const HEADER = [
14
+ "/* GENERATED by stories:build (font-typegen) — 构建期自动生成,手改会在下次构建被覆盖。",
15
+ " 字体名注册表(Resources/Fonts 文件名去扩展名)的全局字面量类型面:键 = 值 = 字体名;",
16
+ " 未注册名不在本类型面,运行期字符串形态 {font=名} 仍走构建期注册表校验。 */",
17
+ ].join("\n");
18
+
19
+ /** 渲染 font.gen.d.ts 全文;空注册表 = 封闭空对象(键访问改拼必报 TS2551) */
20
+ export function renderFontTypegen(entries: readonly FontEntry[]): string {
21
+ const lines = entries.map(
22
+ (entry) =>
23
+ ` readonly ${JSON.stringify(entry.name)}: ${JSON.stringify(entry.name)};`,
24
+ );
25
+ const declaration =
26
+ lines.length === 0
27
+ ? "declare const font: Record<never, never>;"
28
+ : ["declare const font: {", ...lines, "};"].join("\n");
29
+ return `${HEADER}\n\n${declaration}\n`;
30
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 类型面生成器出口(唯一出口)。
3
+ *
4
+ * 生成物落 `Stories.src/gen/stories.gen.d.ts`:列名与插值键在此收窄为字面量联合类型,
5
+ * 拼错列名/插值键在 IDE 与 `tsc --noEmit` 阶段即红,而不是等运行期才发现。
6
+ */
7
+ export { collectDeclaredNames } from "./collect.ts";
8
+ export {
9
+ collectEnumTypeSlots,
10
+ collectEnumTypeSlotsForBuild,
11
+ } from "./state-enum-ast.ts";
12
+ export type { EnumSlotBuildInput, EnumTypeSlot } from "./state-enum-ast.ts";
13
+ export { renderStateTypegen } from "./state-render.ts";
14
+ export type { StateTypegenInput } from "./state-render.ts";
15
+ export { renderScriptTypegen } from "./script-render.ts";
16
+ export { renderFontTypegen } from "./font-render.ts";
17
+ export { renderTypegen } from "./render.ts";
18
+ export type { TypegenInput } from "./types.ts";
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 类型面渲染:把收集好的字符串域落成 `Stories.src/gen/stories.gen.d.ts`。
3
+ *
4
+ * 生成物只做一件事——往 `@lingfan-engine/editor` 的注册表接口里塞字面量键。接口声明在词汇层,
5
+ * 经包根再导出后才可被合并;本文件必须带 `export {};`(没有它就是全局脚本,
6
+ * `declare module` 会整体覆盖该模块而不是合并,包出口会被抹掉)。
7
+ *
8
+ * 空集合也照常产出两个空接口:文件在、结构在,但派生类型退化为 `string`——
9
+ * 不拿空注册表把作者的正常写法判红。
10
+ */
11
+ import type { TypegenInput } from "./types.ts";
12
+
13
+ /** 生成物首行:与 gen/fun_register.g.ts 同一惯例,提醒手改会被覆盖 */
14
+ const HEADER =
15
+ "/* GENERATED by stories:build (typegen) — 手改会在下次构建被覆盖 */";
16
+
17
+ /** 渲染时统一走排序去重:语法树里的键序不保证稳定,同输入必须同输出 */
18
+ function normalize(values: readonly string[]): string[] {
19
+ return [...new Set(values)].sort();
20
+ }
21
+
22
+ /** 渲染一段接口体;空集产出空接口(保留类型扩充点) */
23
+ function renderRegistry(
24
+ lines: string[],
25
+ name: string,
26
+ keys: readonly string[],
27
+ extra: readonly string[] = [],
28
+ ): void {
29
+ lines.push(` interface ${name} {`);
30
+ for (const key of normalize(keys)) {
31
+ lines.push(` ${JSON.stringify(key)}: 1;`);
32
+ }
33
+ for (const line of extra) lines.push(` ${line}`);
34
+ lines.push(" }");
35
+ }
36
+
37
+ /**
38
+ * 扩展 id → 模式索引签名行。
39
+ *
40
+ * 扩展键由扩展 op 在运行期写入(`extOp("quest", { step })` → `ext.demoquest.step`),
41
+ * 键集静态不可知 ⇒ 只能用模板字面量模式签名表达,枚举字面量键是错形态。
42
+ * 这一行是模板语法而非键名,因此**不能走 `JSON.stringify`**(那会渲染成带引号的普通键,
43
+ * 模式语义全丢)。id 已排序去重,行序稳定。
44
+ */
45
+ function extensionSignatures(ids: readonly string[]): string[] {
46
+ return normalize(ids).map((id) => `[k: \`ext.${id}.\${string}\`]: 1;`);
47
+ }
48
+
49
+ /** 渲染类型面全文;末尾带换行,与 renderFunRegister 的收尾一致 */
50
+ export function renderTypegen(input: TypegenInput): string {
51
+ const lines: string[] = [];
52
+ lines.push(HEADER);
53
+ lines.push("export {};");
54
+ lines.push("");
55
+ lines.push('declare module "@lingfan-engine/editor" {');
56
+ renderRegistry(lines, "ColumnNameRegistry", input.columns);
57
+
58
+ // 插值键域 = state 键、config.vars 键、产物声明名、扩展 id 模式四源并集(合并去重后再排序;
59
+ // defineKeys 现取自 config.ts 的 vars 导出——config 化换道后图谱层不再扫 defineVars)
60
+ renderRegistry(
61
+ lines,
62
+ "InterpolationKeyRegistry",
63
+ [...input.schemaKeys, ...input.defineKeys, ...input.declaredNames],
64
+ extensionSignatures(input.extensionPrefixes),
65
+ );
66
+ lines.push("}");
67
+ return `${lines.join("\n")}\n`;
68
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * script 词汇层命名空间的 ambient 类型面渲染器(免导入直呼)。
3
+ *
4
+ * 输出 = `Stories.src/gen/script.gen.d.ts` 全文:一条全局 `declare const script`
5
+ * 指向 `@lingfan-engine/editor` 根出口的 `script` 命名空间类型。纯编译期声明——不注入
6
+ * 任何运行时对象;模块层自有同名声明按 TS 遮蔽语义优先于本全局声明,显式
7
+ * import 与解构形态(含重命名)不受影响。内容恒定、与工程状态无关,同输入逐字节同输出。
8
+ */
9
+
10
+ const HEADER = [
11
+ "/* GENERATED by stories:build (script-typegen) — 构建期自动生成,手改会在下次构建被覆盖。",
12
+ " script 词汇层命名空间的免导入直呼声明:纯编译期类型面,不注入任何运行时对象;",
13
+ " 显式 import 与解构形态优先于本声明(TS 遮蔽语义,重命名不受影响)。 */",
14
+ ].join("\n");
15
+
16
+ const DECLARATION =
17
+ 'declare const script: typeof import("@lingfan-engine/editor").script;';
18
+
19
+ export function renderScriptTypegen(): string {
20
+ return `${HEADER}\n\n${DECLARATION}\n`;
21
+ }