@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.
- package/LICENSE +201 -0
- package/bin/stories-build.mjs +16 -0
- package/bin/stories-watch.mjs +16 -0
- package/package.json +50 -3
- package/src/authoring.d.ts +51 -0
- package/src/cell/errors.ts +12 -0
- package/src/cell/imports.ts +127 -0
- package/src/cell/index.ts +33 -0
- package/src/cell/render-register.ts +33 -0
- package/src/cell/scan-cells.ts +63 -0
- package/src/cell/scan-file.ts +108 -0
- package/src/cell/types.ts +27 -0
- package/src/cell/whitelist.ts +22 -0
- package/src/cli/cli.ts +99 -0
- package/src/cli/watch-cli.ts +42 -0
- package/src/compiler/cell-scan.ts +27 -0
- package/src/compiler/compiler-contract.ts +87 -0
- package/src/compiler/extension-load.ts +67 -0
- package/src/compiler/index.ts +22 -0
- package/src/compiler/story-compiler/assemble/functions.ts +202 -0
- package/src/compiler/story-compiler/assemble/index.ts +8 -0
- package/src/compiler/story-compiler/assemble/session.ts +207 -0
- package/src/compiler/story-compiler/assemble/types.ts +37 -0
- package/src/compiler/story-compiler/assemble.ts +5 -0
- package/src/compiler/story-compiler/graph/build.ts +208 -0
- package/src/compiler/story-compiler/graph/core.ts +102 -0
- package/src/compiler/story-compiler/graph/fold.ts +121 -0
- package/src/compiler/story-compiler/graph/index.ts +17 -0
- package/src/compiler/story-compiler/graph/scan/bindings.ts +277 -0
- package/src/compiler/story-compiler/graph/scan/imports.ts +170 -0
- package/src/compiler/story-compiler/graph/scan/index.ts +9 -0
- package/src/compiler/story-compiler/graph/scan/module-scan.ts +199 -0
- package/src/compiler/story-compiler/graph/state-root.ts +147 -0
- package/src/compiler/story-compiler/graph.ts +18 -0
- package/src/compiler/story-compiler/index.ts +34 -0
- package/src/compiler/story-compiler/translate/expr/assign.ts +204 -0
- package/src/compiler/story-compiler/translate/expr/engine-text.ts +16 -0
- package/src/compiler/story-compiler/translate/expr/expr.ts +174 -0
- package/src/compiler/story-compiler/translate/expr/index.ts +24 -0
- package/src/compiler/story-compiler/translate/expr/lower/index.ts +4 -0
- package/src/compiler/story-compiler/translate/expr/lower/lower.ts +266 -0
- package/src/compiler/story-compiler/translate/expr/lower/numeric.ts +56 -0
- package/src/compiler/story-compiler/translate/expr/probe.ts +23 -0
- package/src/compiler/story-compiler/translate/expr/state-path.ts +47 -0
- package/src/compiler/story-compiler/translate/expr/words/arg.ts +87 -0
- package/src/compiler/story-compiler/translate/expr/words/call.ts +125 -0
- package/src/compiler/story-compiler/translate/expr/words/callee.ts +43 -0
- package/src/compiler/story-compiler/translate/expr/words/command.ts +93 -0
- package/src/compiler/story-compiler/translate/expr/words/dispatch.ts +16 -0
- package/src/compiler/story-compiler/translate/expr/words/index.ts +8 -0
- package/src/compiler/story-compiler/translate/expr/words/static-for.ts +96 -0
- package/src/compiler/story-compiler/translate/font-check.ts +94 -0
- package/src/compiler/story-compiler/translate/index.ts +10 -0
- package/src/compiler/story-compiler/translate/shared.ts +132 -0
- package/src/compiler/story-compiler/translate/stmt/control/branch.ts +71 -0
- package/src/compiler/story-compiler/translate/stmt/control/index.ts +11 -0
- package/src/compiler/story-compiler/translate/stmt/control/iter.ts +72 -0
- package/src/compiler/story-compiler/translate/stmt/control/loop.ts +114 -0
- package/src/compiler/story-compiler/translate/stmt/control/switch.ts +75 -0
- package/src/compiler/story-compiler/translate/stmt/control-flow.ts +25 -0
- package/src/compiler/story-compiler/translate/stmt/index.ts +17 -0
- package/src/compiler/story-compiler/translate/stmt/statements.ts +145 -0
- package/src/compiler/story-compiler/translate.ts +15 -0
- package/src/compiler/story-compiler.ts +119 -0
- package/src/config/index.ts +15 -0
- package/src/config/manifest-read.ts +26 -0
- package/src/config/state-type.ts +85 -0
- package/src/config/story-config.ts +113 -0
- package/src/const-eval/config-eval.ts +181 -0
- package/src/const-eval/const-eval-pass.ts +133 -0
- package/src/const-eval/const-eval.ts +114 -0
- package/src/const-eval/const-expr.ts +358 -0
- package/src/const-eval/contracts.ts +31 -0
- package/src/const-eval/define-merge.ts +195 -0
- package/src/const-eval/diag/compare.ts +39 -0
- package/src/const-eval/diag/index.ts +5 -0
- package/src/const-eval/diag/locate.ts +45 -0
- package/src/const-eval/index.ts +44 -0
- package/src/const-eval/module-characters.ts +353 -0
- package/src/const-eval/module-define.ts +300 -0
- package/src/const-eval/round/evaluate.ts +149 -0
- package/src/const-eval/round/index.ts +4 -0
- package/src/const-eval/round/source.ts +134 -0
- package/src/const-eval/state-schema.ts +269 -0
- package/src/discovery/index.ts +7 -0
- package/src/discovery/source-discovery.ts +193 -0
- package/src/emit/compile-roundtrip.ts +45 -0
- package/src/emit/emit.ts +147 -0
- package/src/emit/index.ts +9 -0
- package/src/emit/report.ts +29 -0
- package/src/fonts/font-registry.ts +53 -0
- package/src/fonts/index.ts +7 -0
- package/src/index.ts +76 -0
- package/src/pipeline/gates.ts +107 -0
- package/src/pipeline/index.ts +18 -0
- package/src/pipeline/orchestrate.ts +459 -0
- package/src/pipeline/watch.ts +244 -0
- package/src/shared/errors.ts +2 -0
- package/src/shared/index.ts +11 -0
- package/src/shared/texts.ts +85 -0
- package/src/shared/walk-files.ts +18 -0
- package/src/typegen/collect.ts +107 -0
- package/src/typegen/font-render.ts +30 -0
- package/src/typegen/index.ts +18 -0
- package/src/typegen/render.ts +68 -0
- package/src/typegen/script-render.ts +21 -0
- package/src/typegen/state-enum-ast.ts +365 -0
- package/src/typegen/state-render.ts +242 -0
- package/src/typegen/types.ts +21 -0
- package/README.md +0 -3
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InferStateSchema:把 config.state 数据声明推导为强类型(TS 原生推导,运行期零开销)。
|
|
3
|
+
* 形态 A 的 StoryState
|
|
4
|
+
* 类型面 = `InferStateSchema<typeof state>`,由消费工程按需引用,不再依赖 lib 约定。
|
|
5
|
+
*
|
|
6
|
+
* schema 值文法(与编排层 state-schema.ts 的降维一一对应;源形态统一纯嵌套对象,
|
|
7
|
+
* 点路径键已砍——扁平 defines 键由降维展平产生):
|
|
8
|
+
* - shorthand 字面量(100 / "other" / true)→ 宽类型,嵌套字面量对象递归展开;
|
|
9
|
+
* - descriptor { type: X; default: D } → 类型只由 X 决定:保留字 "number"|"string"|"boolean"
|
|
10
|
+
* 映射原生类型;X 为枚举对象 → 成员值联合(X[keyof X]);X 为单元素数组 [Leaf] →
|
|
11
|
+
* InferLeaf<Leaf>[];X 为多元素数组(as const 元组)→ 成员槽位并集
|
|
12
|
+
* (InferUnionMember:保留字/null/嵌套并集/[leaf]/枚举对象);其余兜底 unknown;
|
|
13
|
+
* - 嵌套对象递归展开。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
type Primitive = string | number | boolean;
|
|
17
|
+
|
|
18
|
+
/** descriptor 的 type 槽位 → TS 类型;多元素数组(as const 元组)先行判为并集。 */
|
|
19
|
+
type InferTypeRef<X> = X extends readonly [unknown, unknown, ...unknown[]]
|
|
20
|
+
? InferUnionMember<X[number]>
|
|
21
|
+
: X extends "number"
|
|
22
|
+
? number
|
|
23
|
+
: X extends "string"
|
|
24
|
+
? string
|
|
25
|
+
: X extends "boolean"
|
|
26
|
+
? boolean
|
|
27
|
+
: X extends readonly unknown[]
|
|
28
|
+
? X extends readonly (infer Leaf)[]
|
|
29
|
+
? InferLeaf<Leaf>[]
|
|
30
|
+
: never
|
|
31
|
+
: X[keyof X] extends Primitive
|
|
32
|
+
? X[keyof X]
|
|
33
|
+
: unknown;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 并集成员 → TS 类型(分布条件类型:对成员联合逐成员求值)。
|
|
37
|
+
* 顺序即优先级:保留字 → null → 多元素元组(嵌套并集递归)→ 单元素数组 → 枚举对象。
|
|
38
|
+
*/
|
|
39
|
+
type InferUnionMember<M> = M extends "number"
|
|
40
|
+
? number
|
|
41
|
+
: M extends "string"
|
|
42
|
+
? string
|
|
43
|
+
: M extends "boolean"
|
|
44
|
+
? boolean
|
|
45
|
+
: M extends null
|
|
46
|
+
? null
|
|
47
|
+
: M extends readonly [unknown, unknown, ...unknown[]]
|
|
48
|
+
? InferUnionMember<M[number]>
|
|
49
|
+
: M extends readonly (infer Leaf)[]
|
|
50
|
+
? InferLeaf<Leaf>[]
|
|
51
|
+
: M[keyof M] extends Primitive
|
|
52
|
+
? M[keyof M]
|
|
53
|
+
: unknown;
|
|
54
|
+
|
|
55
|
+
/** 数组 descriptor 的元素类型 → TS 类型。 */
|
|
56
|
+
type InferLeaf<L> = L extends "number"
|
|
57
|
+
? number
|
|
58
|
+
: L extends "string"
|
|
59
|
+
? string
|
|
60
|
+
: L extends "boolean"
|
|
61
|
+
? boolean
|
|
62
|
+
: L[keyof L] extends Primitive
|
|
63
|
+
? L[keyof L]
|
|
64
|
+
: never;
|
|
65
|
+
|
|
66
|
+
/** 单个 schema 值 → TS 类型:descriptor 优先,其次 shorthand 宽类型,嵌套对象递归。 */
|
|
67
|
+
type InferValue<V> = V extends {
|
|
68
|
+
readonly type: unknown;
|
|
69
|
+
readonly default: unknown;
|
|
70
|
+
}
|
|
71
|
+
? InferTypeRef<V["type"]>
|
|
72
|
+
: V extends string
|
|
73
|
+
? string
|
|
74
|
+
: V extends number
|
|
75
|
+
? number
|
|
76
|
+
: V extends boolean
|
|
77
|
+
? boolean
|
|
78
|
+
: V extends Record<PropertyKey, unknown>
|
|
79
|
+
? { -readonly [K in keyof V]: InferValue<V[K]> }
|
|
80
|
+
: unknown;
|
|
81
|
+
|
|
82
|
+
/** schema 以 as const 声明(字面量/descriptor 判别需要),但 state 运行期可写——推导全程剥 readonly。 */
|
|
83
|
+
export type InferStateSchema<T> = {
|
|
84
|
+
-readonly [K in keyof T]: InferValue<T[K]>;
|
|
85
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { isSafeFileNameSegment } from "@lingfan-engine/core";
|
|
3
|
+
import { StoryBuildError } from "../shared/index.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 源工程布局声明(`Stories.src/config.ts`,根级唯一根文件):
|
|
7
|
+
* `export const folders = [...]; export const start = "...";`(named exports 契约,
|
|
8
|
+
* 求值产物由 const-eval/config-eval 喂入)。
|
|
9
|
+
* - `folders`:故事/模块源目录(相对 `Stories.src/`,递归扫描;`"*"` = 未显式列出的
|
|
10
|
+
* 目录全部自动纳入,排除生成物目录);
|
|
11
|
+
* - `start`:入口**故事文件名**——跨全部目录按文件名匹配,全局恰一(0 或 ≥2 fail-closed);
|
|
12
|
+
* - `version`:可选故事版本(正整数,缺省 1)——进存档 `storyVersion`,
|
|
13
|
+
* 升级时经宿主 `migrateState` 钩子迁移旧档。
|
|
14
|
+
*/
|
|
15
|
+
export interface StorySourceConfig {
|
|
16
|
+
readonly folders: readonly string[];
|
|
17
|
+
readonly start: string;
|
|
18
|
+
readonly version?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 布局声明文件名(根级唯一根 .ts;其余 .ts 一律住 folders 声明的目录里) */
|
|
22
|
+
export const CONFIG_FILE = "config.ts";
|
|
23
|
+
|
|
24
|
+
/** 根级唯一根文件守卫:根层(不递归)出现第二个 .ts 即 fail-closed。 */
|
|
25
|
+
export function assertSingleRootScript(
|
|
26
|
+
sourcesDir: string,
|
|
27
|
+
sourcesDirName: string,
|
|
28
|
+
): void {
|
|
29
|
+
const rootScripts = existsSync(sourcesDir)
|
|
30
|
+
? readdirSync(sourcesDir)
|
|
31
|
+
.filter((name) => name.endsWith(".ts"))
|
|
32
|
+
.sort()
|
|
33
|
+
: [];
|
|
34
|
+
if (rootScripts.length > 1) {
|
|
35
|
+
throw new StoryBuildError(
|
|
36
|
+
`${sourcesDirName}/ 根级发现 ${rootScripts.length} 个 .ts(${rootScripts.join("、")})——根级唯一根文件是 ${CONFIG_FILE};故事与模块源请放进 folders 声明的目录`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** folders 校验:非空数组,元素 = "*" 或相对 `Stories.src/` 的段级安全目录路径;禁指生成物目录。 */
|
|
42
|
+
export function parseFolders(
|
|
43
|
+
value: unknown,
|
|
44
|
+
sourcesDirName: string,
|
|
45
|
+
): readonly string[] {
|
|
46
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
47
|
+
throw new StoryBuildError(
|
|
48
|
+
`${sourcesDirName}/${CONFIG_FILE}: folders 须为非空目录名数组(可含 "*" 兜底)`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const folders = value.map((entry, index) => {
|
|
52
|
+
if (entry === "*") return "*";
|
|
53
|
+
if (typeof entry !== "string") {
|
|
54
|
+
throw new StoryBuildError(
|
|
55
|
+
`${sourcesDirName}/${CONFIG_FILE}: folders[${index}] 须为目录名字符串或 "*",得到 ${describe(entry)}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (entry === "gen" || entry.startsWith("gen/")) {
|
|
59
|
+
throw new StoryBuildError(
|
|
60
|
+
`${sourcesDirName}/${CONFIG_FILE}: folders[${index}] "${entry}" 指向生成物目录——gen/ 由构建管理,不参与源扫描`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (
|
|
64
|
+
entry.length === 0 ||
|
|
65
|
+
entry.includes("\\") ||
|
|
66
|
+
!entry.split("/").every((segment) => isSafeFileNameSegment(segment))
|
|
67
|
+
) {
|
|
68
|
+
throw new StoryBuildError(
|
|
69
|
+
`${sourcesDirName}/${CONFIG_FILE}: folders[${index}] "${entry}" 不是合法目录名(须为相对 ${sourcesDirName}/ 的段级安全路径)`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return entry;
|
|
73
|
+
});
|
|
74
|
+
return folders;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** start 校验:含 .ts 的故事文件名(如 "main.ts"),不含目录——目录归属由 folders 管。 */
|
|
78
|
+
export function parseStart(value: unknown, sourcesDirName: string): string {
|
|
79
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
80
|
+
throw new StoryBuildError(
|
|
81
|
+
`${sourcesDirName}/${CONFIG_FILE}: start 须为入口故事文件名(如 "main.ts")`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (value.includes("/") || value.includes("\\")) {
|
|
85
|
+
throw new StoryBuildError(
|
|
86
|
+
`${sourcesDirName}/${CONFIG_FILE}: start "${value}" 须为文件名不含目录——目录布局归 folders 管`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (!value.endsWith(".ts")) {
|
|
90
|
+
throw new StoryBuildError(
|
|
91
|
+
`${sourcesDirName}/${CONFIG_FILE}: start "${value}" 须含 .ts 扩展名(如 "main.ts")`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** version 校验:正整数(存档 storyVersion 语义,读档迁移对账);缺省 = 1(不写导出即缺省)。 */
|
|
98
|
+
export function parseVersion(
|
|
99
|
+
value: unknown,
|
|
100
|
+
sourcesDirName: string,
|
|
101
|
+
): number | undefined {
|
|
102
|
+
if (value === undefined) return undefined;
|
|
103
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
104
|
+
throw new StoryBuildError(
|
|
105
|
+
`${sourcesDirName}/${CONFIG_FILE}: version 须为正整数(进存档 storyVersion;缺省 = 1),得到 ${describe(value)}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function describe(value: unknown): string {
|
|
112
|
+
return value === null ? "null" : Array.isArray(value) ? "数组" : typeof value;
|
|
113
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.ts 求值(编排层唯一的 config 读取通道):
|
|
3
|
+
* `Stories.src/config.ts` 两种声明形态等价,单次求值一并取出,**双形态混用 fail-closed**:
|
|
4
|
+
* - default 对象(推荐):`export default { folders, start, version?, vars?, state? }`
|
|
5
|
+
* —— 强类型单出口(配合 `StoriesConfig` 全局类型做 IDE 补全);
|
|
6
|
+
* - named exports:`export const folders/start/version/vars/state`(兼容既有工程)。
|
|
7
|
+
* 再叠加形态 B 的 define() 累积(与两种 config 形态正交)。
|
|
8
|
+
* - 布局字段复用 parseFolders/parseStart/parseVersion 校验(fail-closed 带定位);
|
|
9
|
+
* - define 调用经 mergeDefineCalls 合并为纯嵌套树(同键二次定义/形状冲突一次报全部);
|
|
10
|
+
* - state 与 define 调用互斥(形态 A/B 二选一,皆缺省合法)。
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import {
|
|
15
|
+
CONFIG_FILE,
|
|
16
|
+
assertSingleRootScript,
|
|
17
|
+
parseFolders,
|
|
18
|
+
parseStart,
|
|
19
|
+
parseVersion,
|
|
20
|
+
type StorySourceConfig,
|
|
21
|
+
} from "../config/story-config.ts";
|
|
22
|
+
import {
|
|
23
|
+
defaultStoriesBuildTexts,
|
|
24
|
+
StoryBuildError,
|
|
25
|
+
type StoriesBuildTexts,
|
|
26
|
+
} from "../shared/index.ts";
|
|
27
|
+
import { evalConstModule } from "./const-eval.ts";
|
|
28
|
+
import { formatConstDiagFile } from "./const-eval-pass.ts";
|
|
29
|
+
import { mergeDefineCalls } from "./define-merge.ts";
|
|
30
|
+
|
|
31
|
+
/** config.ts 单次求值的产物(编排层的 config 消费形态)。 */
|
|
32
|
+
export interface ConfigEvalOutcome {
|
|
33
|
+
/** 布局声明(folders/start,已过校验) */
|
|
34
|
+
readonly config: StorySourceConfig;
|
|
35
|
+
/** `config.vars` 导出的键(typegen defineKeys 域贡献);缺省 = 空数组 */
|
|
36
|
+
readonly vars: readonly string[];
|
|
37
|
+
/** `config.state` 导出原始树(形态 A);缺省 = undefined(与 define 累积互斥) */
|
|
38
|
+
readonly state: unknown;
|
|
39
|
+
/** 形态 B define() 调用合并出的纯嵌套树;无调用 = 空对象 */
|
|
40
|
+
readonly defineTree: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function evalConfigModule(
|
|
44
|
+
root: string,
|
|
45
|
+
sourcesDirName: string,
|
|
46
|
+
texts: StoriesBuildTexts = defaultStoriesBuildTexts,
|
|
47
|
+
timeoutMs?: number,
|
|
48
|
+
): Promise<ConfigEvalOutcome> {
|
|
49
|
+
const sourcesDir = join(root, sourcesDirName);
|
|
50
|
+
const configPath = join(sourcesDir, CONFIG_FILE);
|
|
51
|
+
if (!existsSync(configPath)) {
|
|
52
|
+
throw new StoryBuildError(texts.configMissing(sourcesDirName, CONFIG_FILE));
|
|
53
|
+
}
|
|
54
|
+
assertSingleRootScript(sourcesDir, sourcesDirName);
|
|
55
|
+
|
|
56
|
+
const result = await evalConstModule(configPath, timeoutMs);
|
|
57
|
+
if ("diags" in result) {
|
|
58
|
+
throw new StoryBuildError(
|
|
59
|
+
`${sourcesDirName}/${CONFIG_FILE} 求值失败(${result.diags.length} 条):\n${result.diags
|
|
60
|
+
.map(
|
|
61
|
+
(diag) =>
|
|
62
|
+
`- ${formatConstDiagFile(diag.file, sourcesDir, sourcesDirName)}:${diag.line}:${diag.col} ${diag.message}`,
|
|
63
|
+
)
|
|
64
|
+
.join("\n")}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const values = result.values;
|
|
68
|
+
|
|
69
|
+
// 读取通道:default 对象优先,named exports 兜底;混用即报(契约不能有两个来源)。
|
|
70
|
+
const hasDefault = "default" in values;
|
|
71
|
+
const hasNamedConfig = ["folders", "start", "version", "vars", "state"].some(
|
|
72
|
+
(key) => key in values,
|
|
73
|
+
);
|
|
74
|
+
if (hasDefault && hasNamedConfig) {
|
|
75
|
+
throw new StoryBuildError(
|
|
76
|
+
`${sourcesDirName}/${CONFIG_FILE}: default 对象与 named exports(folders/start/version/vars/state)混用——两种声明形态二选一`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
let foldersRaw: unknown;
|
|
80
|
+
let startRaw: unknown;
|
|
81
|
+
let versionRaw: unknown;
|
|
82
|
+
let varsRaw: unknown;
|
|
83
|
+
let stateRaw: unknown;
|
|
84
|
+
if (hasDefault) {
|
|
85
|
+
const spec = values.default;
|
|
86
|
+
if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
|
|
87
|
+
throw new StoryBuildError(
|
|
88
|
+
`${sourcesDirName}/${CONFIG_FILE}: default 导出须为配置对象 { folders, start, version?, vars?, state? },得到 ${describe(spec)}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const obj = spec as Record<string, unknown>;
|
|
92
|
+
if (!("folders" in obj) || !("start" in obj)) {
|
|
93
|
+
const missing = ["folders", "start"].filter((key) => !(key in obj));
|
|
94
|
+
throw new StoryBuildError(
|
|
95
|
+
`${sourcesDirName}/${CONFIG_FILE}: default 配置缺少 ${missing.join("、")} 字段(folders/start 必需,version/vars/state 可缺省)`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
foldersRaw = obj.folders;
|
|
99
|
+
startRaw = obj.start;
|
|
100
|
+
versionRaw = obj.version;
|
|
101
|
+
varsRaw = "vars" in obj ? obj.vars : undefined;
|
|
102
|
+
stateRaw = "state" in obj ? obj.state : undefined;
|
|
103
|
+
} else {
|
|
104
|
+
foldersRaw = requireExport(values, "folders", sourcesDirName);
|
|
105
|
+
startRaw = requireExport(values, "start", sourcesDirName);
|
|
106
|
+
versionRaw = values.version;
|
|
107
|
+
varsRaw = values.vars;
|
|
108
|
+
stateRaw = "state" in values ? values.state : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const folders = parseFolders(foldersRaw, sourcesDirName);
|
|
112
|
+
const start = parseStart(startRaw, sourcesDirName);
|
|
113
|
+
const version = parseVersion(versionRaw, sourcesDirName);
|
|
114
|
+
|
|
115
|
+
const vars = readVarsKeySet(varsRaw, sourcesDirName);
|
|
116
|
+
const hasState = stateRaw !== undefined;
|
|
117
|
+
if (
|
|
118
|
+
hasState &&
|
|
119
|
+
(stateRaw === null ||
|
|
120
|
+
typeof stateRaw !== "object" ||
|
|
121
|
+
Array.isArray(stateRaw))
|
|
122
|
+
) {
|
|
123
|
+
throw new StoryBuildError(
|
|
124
|
+
`${sourcesDirName}/${CONFIG_FILE}: state 须为键集对象(形态 A:state = { ... } as const),得到 ${describe(stateRaw)}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const merged = mergeDefineCalls(result.defineCalls);
|
|
129
|
+
if (merged.conflicts.length > 0) {
|
|
130
|
+
throw new StoryBuildError(
|
|
131
|
+
`${sourcesDirName}/${CONFIG_FILE}: define 声明冲突(${merged.conflicts.length} 条):\n${merged.conflicts
|
|
132
|
+
.map((conflict) => `- ${conflict}`)
|
|
133
|
+
.join("\n")}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (hasState && result.defineCalls.length > 0) {
|
|
137
|
+
throw new StoryBuildError(
|
|
138
|
+
`${sourcesDirName}/${CONFIG_FILE}: 同时出现 state 声明与 define() 调用——形态 A/B 二选一(同文件混用即报错;两者皆缺省合法)`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
config: { folders, start, version },
|
|
144
|
+
vars,
|
|
145
|
+
state: hasState ? stateRaw : undefined,
|
|
146
|
+
defineTree: merged.tree,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** named exports 形态的布局必需导出(folders/start);缺省 = fail-closed 并说明契约。 */
|
|
151
|
+
function requireExport(
|
|
152
|
+
values: Record<string, unknown>,
|
|
153
|
+
name: string,
|
|
154
|
+
sourcesDirName: string,
|
|
155
|
+
): unknown {
|
|
156
|
+
if (!(name in values)) {
|
|
157
|
+
throw new StoryBuildError(
|
|
158
|
+
`${sourcesDirName}/${CONFIG_FILE}: 缺少 ${name} 导出(config.ts 契约:default 对象或 folders/start named exports 二选一,vars/state/version 可缺省)`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return values[name];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** vars 键集提取(键 = typegen defineKeys 域贡献;值文法不在此校验,与旧图谱扫描同宽)。 */
|
|
165
|
+
function readVarsKeySet(
|
|
166
|
+
varsRaw: unknown,
|
|
167
|
+
sourcesDirName: string,
|
|
168
|
+
): readonly string[] {
|
|
169
|
+
if (varsRaw === undefined) return [];
|
|
170
|
+
const spec = varsRaw;
|
|
171
|
+
if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
|
|
172
|
+
throw new StoryBuildError(
|
|
173
|
+
`${sourcesDirName}/${CONFIG_FILE}: vars 须为键集对象(defineVars({ ... }) 的返回值),得到 ${describe(spec)}`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return Object.keys(spec as Record<string, unknown>);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function describe(value: unknown): string {
|
|
180
|
+
return value === null ? "null" : Array.isArray(value) ? "数组" : typeof value;
|
|
181
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 编译期求值接入段(编排层):扫描源清单,把含 node_modules 值导入的文件
|
|
3
|
+
* 整体交给 const-eval 求值,产出「文件 → 导出名 → JSON 值」喂给 story-compiler 内联。
|
|
4
|
+
*
|
|
5
|
+
* 触发判据(npm 值导入边界):静态 import 的模块说明符非相对路径、非 @lingfan-engine/editor、
|
|
6
|
+
* 非 @lingfan-engine/core,且存在值绑定(default / 命名空间 / 任一非 type 具名绑定)。
|
|
7
|
+
* `import type` 整句编译期擦除,不触发求值;副作用导入同样不触发(图谱层自有
|
|
8
|
+
* fail-closed 诊断)。
|
|
9
|
+
*
|
|
10
|
+
* fail-closed(一次报全部):
|
|
11
|
+
* - 故事文件含 node_modules 值导入 → 定向诊断(求值器只作用于纯模块;故事文件的
|
|
12
|
+
* default 函数导出必然不可序列化,npm 值请收敛为纯模块的导出 const 再相对导入);
|
|
13
|
+
* - 求值诊断(不可序列化导出 / 顶层抛错 / 超时 / esbuild 失败)→ 原样透传;
|
|
14
|
+
* - 双求值不一致(E13 确定性闸门,逐键 deepEqual)内建于 evalConstModule,诊断同此透传。
|
|
15
|
+
*
|
|
16
|
+
* 注意:被求值文件整体求值,因此不得再导出函数/类等非序列化值——请把函数与
|
|
17
|
+
* node_modules 值分模块放置(函数导出留在普通支撑模块)。
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import * as ts from "typescript";
|
|
22
|
+
import { evalConstModule } from "./const-eval.ts";
|
|
23
|
+
import type { CompilerConstValues } from "../compiler/index.ts";
|
|
24
|
+
import {
|
|
25
|
+
defaultStoriesBuildTexts,
|
|
26
|
+
StoryBuildError,
|
|
27
|
+
type StoriesBuildTexts,
|
|
28
|
+
} from "../shared/index.ts";
|
|
29
|
+
|
|
30
|
+
/** 词汇层两包不触发求值:editor 是词汇出口(图谱层按词/命名空间绑定),engine 不参与创作导入。 */
|
|
31
|
+
const RESERVED_SPECS: ReadonlySet<string> = new Set([
|
|
32
|
+
"@lingfan-engine/editor",
|
|
33
|
+
"@lingfan-engine/core",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
interface NpmValueImport {
|
|
37
|
+
spec: string;
|
|
38
|
+
line: number;
|
|
39
|
+
col: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function findNpmValueImports(rel: string, text: string): NpmValueImport[] {
|
|
43
|
+
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true);
|
|
44
|
+
const hits: NpmValueImport[] = [];
|
|
45
|
+
for (const stmt of sf.statements) {
|
|
46
|
+
if (!ts.isImportDeclaration(stmt)) continue;
|
|
47
|
+
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
48
|
+
const spec = stmt.moduleSpecifier.text;
|
|
49
|
+
if (spec.startsWith(".") || RESERVED_SPECS.has(spec)) continue;
|
|
50
|
+
const clause = stmt.importClause;
|
|
51
|
+
if (clause === undefined) continue; // 副作用导入:不触发求值(图谱层自有诊断)
|
|
52
|
+
if (clause.isTypeOnly) continue; // import type:编译期擦除,不在 npm 值导入约束内
|
|
53
|
+
const hasValueBinding =
|
|
54
|
+
clause.name !== undefined ||
|
|
55
|
+
(clause.namedBindings !== undefined &&
|
|
56
|
+
(ts.isNamespaceImport(clause.namedBindings) ||
|
|
57
|
+
(ts.isNamedImports(clause.namedBindings) &&
|
|
58
|
+
clause.namedBindings.elements.some((el) => !el.isTypeOnly))));
|
|
59
|
+
if (!hasValueBinding) continue;
|
|
60
|
+
const pos = sf.getLineAndCharacterOfPosition(stmt.getStart(sf));
|
|
61
|
+
hits.push({ spec, line: pos.line + 1, col: pos.character + 1 });
|
|
62
|
+
}
|
|
63
|
+
return hits;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 求值段:返回喂给编译器的 constValues(无触发文件 = 空对象,行为与未接入一致)。 */
|
|
67
|
+
export async function runConstEvalPass(
|
|
68
|
+
sourcesDir: string,
|
|
69
|
+
storyFiles: readonly string[],
|
|
70
|
+
moduleFiles: readonly string[],
|
|
71
|
+
sourcesDirName: string,
|
|
72
|
+
texts: StoriesBuildTexts = defaultStoriesBuildTexts,
|
|
73
|
+
timeoutMs?: number,
|
|
74
|
+
): Promise<CompilerConstValues> {
|
|
75
|
+
const storySet = new Set(storyFiles);
|
|
76
|
+
const failures: string[] = [];
|
|
77
|
+
const constValues: Record<string, Record<string, unknown>> = {};
|
|
78
|
+
// 排序保证诊断顺序确定(同一份输入 ⇒ 同一份报告)。
|
|
79
|
+
for (const rel of [...new Set([...storyFiles, ...moduleFiles])].sort()) {
|
|
80
|
+
let text: string;
|
|
81
|
+
try {
|
|
82
|
+
text = readFileSync(join(sourcesDir, rel), "utf8");
|
|
83
|
+
} catch {
|
|
84
|
+
continue; // 文件消失等读取异常交由源发现/编译段按既有路径报错
|
|
85
|
+
}
|
|
86
|
+
const hits = findNpmValueImports(rel, text);
|
|
87
|
+
if (hits.length === 0) continue;
|
|
88
|
+
if (storySet.has(rel)) {
|
|
89
|
+
for (const hit of hits) {
|
|
90
|
+
failures.push(
|
|
91
|
+
texts.storyNpmValueImport(
|
|
92
|
+
rel,
|
|
93
|
+
hit.line,
|
|
94
|
+
hit.col,
|
|
95
|
+
hit.spec,
|
|
96
|
+
sourcesDirName,
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const result = await evalConstModule(join(sourcesDir, rel), timeoutMs);
|
|
103
|
+
if ("diags" in result) {
|
|
104
|
+
failures.push(
|
|
105
|
+
...result.diags.map(
|
|
106
|
+
(diag) =>
|
|
107
|
+
`- ${formatConstDiagFile(diag.file, sourcesDir, sourcesDirName)}:${diag.line}:${diag.col} ${diag.message}`,
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
constValues[rel] = result.values;
|
|
113
|
+
}
|
|
114
|
+
if (failures.length > 0) {
|
|
115
|
+
throw new StoryBuildError(
|
|
116
|
+
`编译期求值失败(${failures.length} 条):\n${failures.join("\n")}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return constValues;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** const-eval 诊断的 file 是绝对入口路径;归一为 `Stories.src/<rel>` 与其余诊断同构。 */
|
|
123
|
+
export function formatConstDiagFile(
|
|
124
|
+
file: string,
|
|
125
|
+
sourcesDir: string,
|
|
126
|
+
sourcesDirName: string,
|
|
127
|
+
): string {
|
|
128
|
+
const norm = file.replace(/\\/g, "/");
|
|
129
|
+
const prefix = `${sourcesDir.replace(/\\/g, "/").replace(/\/$/, "")}/`;
|
|
130
|
+
return norm.startsWith(prefix)
|
|
131
|
+
? `${sourcesDirName}/${norm.slice(prefix.length)}`
|
|
132
|
+
: norm;
|
|
133
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 编译期求值器(const-eval):把 Stories.src 模块的模块层 const 初始化链求值成
|
|
3
|
+
* JSON 可序列化值,供 story-compiler 后续接入(const 内联 / staticFor 展开等)。
|
|
4
|
+
*
|
|
5
|
+
* 管线(每轮一遍,共两轮):
|
|
6
|
+
* 1. esbuild 把入口模块的依赖链(含 node_modules 包)打包为内存 bundle(不落盘);
|
|
7
|
+
* 2. 在隔离 worker(node worker_threads)中执行 bundle,取导出常量值;
|
|
8
|
+
* 3. 导出分类:仅接受 JSON 可序列化值;函数 / undefined / symbol / bigint / NaN /
|
|
9
|
+
* 非普通对象(Date/Map/Set/class 实例)等一律报诊断。
|
|
10
|
+
* 4. 构建期内建注入:`define` / `defineVars` 以求值环境实参进 factory(无运行时实体)——
|
|
11
|
+
* define 调用按书写序累积成 DefineCall 序列(形态 B),defineVars 返回实参本身
|
|
12
|
+
* (`export const vars = defineVars({...})` 的导出值 = 键集对象,即 config.vars 键来源)。
|
|
13
|
+
*
|
|
14
|
+
* 确定性闸门(fail-closed):同一 entry 求值两次,逐键 deepEqual;不一致即报错,
|
|
15
|
+
* 文案指明禁 Date / Math.random / IO 等不确定源。
|
|
16
|
+
*
|
|
17
|
+
* 硬性约束:
|
|
18
|
+
* - 求值只执行模块顶层代码;导出的函数体(含故事默认导出函数体)从不被调用;
|
|
19
|
+
* - esbuild 打包与求值都在 worker 内完成,主进程不被模块顶层代码污染;
|
|
20
|
+
* - 本模块不修改 build/ 下其他既有文件,也不修改 packages/ 任何代码。
|
|
21
|
+
*
|
|
22
|
+
* 分文件:共享契约 `./contracts.ts`;worker 轮次 `./round/`;诊断辅助 `./diag/`。
|
|
23
|
+
*/
|
|
24
|
+
import { createRequire } from "node:module";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import type { ConstEvalResult } from "./contracts.ts";
|
|
27
|
+
import { deepEqual, firstDeterminismDiff } from "./diag/index.ts";
|
|
28
|
+
import { evaluateOnce } from "./round/index.ts";
|
|
29
|
+
|
|
30
|
+
/** 契约类型仍从本文件转出(同域兄弟模块按 `./const-eval.ts` 取件,取件面不变)。 */
|
|
31
|
+
export type {
|
|
32
|
+
ConstEvalResult,
|
|
33
|
+
DefineCall,
|
|
34
|
+
DefineBad,
|
|
35
|
+
JsonValue,
|
|
36
|
+
} from "./contracts.ts";
|
|
37
|
+
|
|
38
|
+
/** 单轮求值超时缺省值(毫秒;模块顶层死循环不应拖死构建)。 */
|
|
39
|
+
export const DEFAULT_EVAL_TIMEOUT_MS = 30_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 求值入口:`entry` = Stories.src 内源文件绝对路径。
|
|
43
|
+
* `timeoutMs` = 单轮求值时限(缺省 30_000;编排入口 evalTimeoutMs 逐层透传至此)。
|
|
44
|
+
* 返回该模块全部导出常量的 JSON 可序列化值;任何不可序列化导出、打包失败、
|
|
45
|
+
* 顶层执行抛错、求值超时或两次求值不一致,都返回诊断集(fail-closed)。
|
|
46
|
+
*/
|
|
47
|
+
export async function evalConstModule(
|
|
48
|
+
entry: string,
|
|
49
|
+
timeoutMs: number = DEFAULT_EVAL_TIMEOUT_MS,
|
|
50
|
+
): Promise<ConstEvalResult> {
|
|
51
|
+
const absEntry = path.resolve(entry);
|
|
52
|
+
const esbuildPath = resolveEsbuildPath();
|
|
53
|
+
if (esbuildPath === null) {
|
|
54
|
+
return {
|
|
55
|
+
diags: [
|
|
56
|
+
{
|
|
57
|
+
file: absEntry,
|
|
58
|
+
line: 1,
|
|
59
|
+
col: 1,
|
|
60
|
+
message:
|
|
61
|
+
"esbuild 未解析到——@lingfan-engine/stories-build 依赖 esbuild 完成依赖链打包," +
|
|
62
|
+
"请确认该依赖已装好(本包从自身位置解析 esbuild,不借用调用方工程的 node_modules)",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const evaluateRound = (): Promise<ConstEvalResult> =>
|
|
69
|
+
evaluateOnce(absEntry, esbuildPath, timeoutMs);
|
|
70
|
+
|
|
71
|
+
const first = await evaluateRound();
|
|
72
|
+
if ("diags" in first) return first;
|
|
73
|
+
const second = await evaluateRound();
|
|
74
|
+
if ("diags" in second) return second;
|
|
75
|
+
|
|
76
|
+
const differingKey = firstDeterminismDiff(first.values, second.values);
|
|
77
|
+
if (differingKey !== null) {
|
|
78
|
+
return {
|
|
79
|
+
diags: [
|
|
80
|
+
{
|
|
81
|
+
file: absEntry,
|
|
82
|
+
line: 1,
|
|
83
|
+
col: 1,
|
|
84
|
+
message:
|
|
85
|
+
`编译期求值不确定:导出 "${differingKey}" 在两次求值间不一致 —— ` +
|
|
86
|
+
"模块层 const 必须确定性求值:禁用 Date(new Date()/Date.now())、Math.random、文件/网络 IO 等不确定源",
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (!deepEqual(first.defineCalls, second.defineCalls)) {
|
|
92
|
+
return {
|
|
93
|
+
diags: [
|
|
94
|
+
{
|
|
95
|
+
file: absEntry,
|
|
96
|
+
line: 1,
|
|
97
|
+
col: 1,
|
|
98
|
+
message:
|
|
99
|
+
"编译期求值不确定:define() 声明在两次求值间不一致 —— " +
|
|
100
|
+
"模块顶层代码必须确定性求值:禁用 Date(new Date()/Date.now())、Math.random、文件/网络 IO 等不确定源",
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { values: first.values, defineCalls: first.defineCalls };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveEsbuildPath(): string | null {
|
|
109
|
+
try {
|
|
110
|
+
return createRequire(import.meta.url).resolve("esbuild");
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|