@tbox.cn/app-toolkit 0.1.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.
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/dist/chunk-VV3ERZXS.js +1 -0
- package/dist/contracts-resolver-QM4FBQQV.js +1 -0
- package/dist/index.d.ts +1909 -0
- package/dist/index.js +8 -0
- package/package.json +47 -0
- package/src/assembly/app-manifest.ts +132 -0
- package/src/assembly/provider-catalog.ts +167 -0
- package/src/assembly/walk-manifests.ts +461 -0
- package/src/core/contracts-expected.ts +31 -0
- package/src/core/contracts-resolver.ts +274 -0
- package/src/core/credential-format.ts +98 -0
- package/src/core/credential-mask.ts +19 -0
- package/src/core/env-expansion.ts +51 -0
- package/src/core/errors.ts +46 -0
- package/src/core/file-cache.ts +65 -0
- package/src/core/fskit.ts +21 -0
- package/src/core/module-schema.ts +139 -0
- package/src/dto.ts +298 -0
- package/src/factory.ts +297 -0
- package/src/index.ts +137 -0
- package/src/integrations/credentials.ts +153 -0
- package/src/integrations/mock-bindings.ts +55 -0
- package/src/integrations/predicate-io.ts +199 -0
- package/src/integrations/predicate.ts +598 -0
- package/src/integrations/read.ts +53 -0
- package/src/integrations/write.ts +448 -0
- package/src/views/app.ts +28 -0
- package/src/views/context.ts +70 -0
- package/src/views/modules.ts +48 -0
- package/src/views/providers.ts +165 -0
- package/src/views/service-detail.ts +32 -0
- package/src/views/service-resolutions.ts +323 -0
- package/tests/contracts-resolver.test.ts +203 -0
- package/tests/demo-app.ts +146 -0
- package/tests/dev-manifest-catalog.test.ts +114 -0
- package/tests/error-name-safety.test.ts +26 -0
- package/tests/file-cache.test.ts +242 -0
- package/tests/import-layers.test.ts +111 -0
- package/tests/module-schema.test.ts +84 -0
- package/tests/naming-alignment.test.ts +50 -0
- package/tests/views.test.ts +427 -0
- package/tests/write-core.test.ts +188 -0
- package/tsconfig.json +11 -0
- package/tsup.config.ts +29 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync, statSync, writeFileSync, mkdirSync, rmSync, mkdtempSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createFileCache } from '../src/core/file-cache.js';
|
|
6
|
+
import { writeAtomic } from '../src/core/fskit.js';
|
|
7
|
+
import { resolveContractsForApp, invalidateContractsResolver } from '../src/core/contracts-resolver.js';
|
|
8
|
+
import { createTempApp, writeFakeContractsPackage, linkContracts, writeAppFile, bumpMtime, type TempApp } from './demo-app.js';
|
|
9
|
+
import { walkManifests } from '../src/assembly/walk-manifests.js';
|
|
10
|
+
import { createAppToolkit } from '../src/factory.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 一致性矩阵 W1-W7 = **Freshness Contract 验收面**(技术方案 §2.5 单源)。
|
|
14
|
+
* C2 落 W1/W2/W4/W5/W6(file-cache/fskit/resolver 原语级);
|
|
15
|
+
* W3(模块装卸枚举零缓存)/W7(agent 动线矩阵——工厂方法级)随 walkManifests(C3)与
|
|
16
|
+
* views 工厂(C4)落地补全——本文件即验收面挂载点,不另立文件。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
describe('file-cache / Freshness Contract', () => {
|
|
20
|
+
it('W1 read-your-writes:写 → 同实例读反映写入;同毫秒连写两次 → 读到第二次内容', () => {
|
|
21
|
+
const cache = createFileCache();
|
|
22
|
+
const file = join(mkdtempSync(join(tmpdir(), 'tbox-w1-')), 'data.json');
|
|
23
|
+
writeAtomic(file, '{"v":1}');
|
|
24
|
+
expect(cache.read(file, JSON.parse)).toEqual({ v: 1 });
|
|
25
|
+
// 同毫秒连写两次(mtime 粒度陷阱)——写后失效兜底,不依赖 mtime
|
|
26
|
+
writeAtomic(file, '{"v":2}');
|
|
27
|
+
cache.invalidate([file]);
|
|
28
|
+
writeAtomic(file, '{"v":3}');
|
|
29
|
+
cache.invalidate([file]);
|
|
30
|
+
expect(cache.read(file, JSON.parse)).toEqual({ v: 3 });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('W2 外部写自愈:绕过 toolkit 直写文件 → 下一笔读 stat 指纹不命中 → 重读', () => {
|
|
34
|
+
const cache = createFileCache();
|
|
35
|
+
const file = join(mkdtempSync(join(tmpdir(), 'tbox-w2-')), 'data.json');
|
|
36
|
+
writeFileSync(file, '{"v":"old"}');
|
|
37
|
+
expect(cache.read(file, JSON.parse)).toEqual({ v: 'old' });
|
|
38
|
+
// 外部写(模拟 files API / CLI / agent)——mtimeMs+size 指纹变化;
|
|
39
|
+
// mtime 前移:粗粒度 fs(CI overlayfs)同 tick 同 size 重写指纹不变属边界②,显式模拟更晚写入
|
|
40
|
+
writeFileSync(file, '{"v":"new"}');
|
|
41
|
+
bumpMtime(file);
|
|
42
|
+
expect(cache.read(file, JSON.parse)).toEqual({ v: 'new' });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('W5 写中读无撕裂:原子写并发读 → 恒完整 JSON(旧完整或新完整,永不半截)', () => {
|
|
46
|
+
const cache = createFileCache();
|
|
47
|
+
const dir = mkdtempSync(join(tmpdir(), 'tbox-w5-'));
|
|
48
|
+
const file = join(dir, 'data.json');
|
|
49
|
+
writeAtomic(file, JSON.stringify({ big: 'a'.repeat(4096) }));
|
|
50
|
+
const writer = setInterval(() => {
|
|
51
|
+
writeAtomic(file, JSON.stringify({ big: Math.random().toString(36).repeat(256) }));
|
|
52
|
+
}, 1);
|
|
53
|
+
try {
|
|
54
|
+
for (let i = 0; i < 200; i += 1) {
|
|
55
|
+
const parsed = cache.read(file, (raw) => JSON.parse(raw) as { big: string });
|
|
56
|
+
expect(typeof parsed?.big).toBe('string');
|
|
57
|
+
}
|
|
58
|
+
} finally {
|
|
59
|
+
clearInterval(writer);
|
|
60
|
+
}
|
|
61
|
+
rmSync(dir, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('W6 缓存命中不重 parse:二次读同文件 parse 调用不增(spy 计数)', () => {
|
|
65
|
+
const cache = createFileCache();
|
|
66
|
+
const file = join(mkdtempSync(join(tmpdir(), 'tbox-w6-')), 'data.json');
|
|
67
|
+
writeFileSync(file, '{"n":1}');
|
|
68
|
+
let parseCalls = 0;
|
|
69
|
+
const parse = (raw: string) => {
|
|
70
|
+
parseCalls += 1;
|
|
71
|
+
return JSON.parse(raw) as { n: number };
|
|
72
|
+
};
|
|
73
|
+
cache.read(file, parse);
|
|
74
|
+
expect(parseCalls).toBe(1);
|
|
75
|
+
cache.read(file, parse);
|
|
76
|
+
cache.read(file, parse);
|
|
77
|
+
expect(parseCalls).toBe(1); // 命中路径零 parse
|
|
78
|
+
expect(cache.parseCount).toBe(1);
|
|
79
|
+
// 同 stat 指纹外力覆写不可觉察是 Freshness Contract 边界②——此处不模拟
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('invalidate(paths) 定向失效;invalidate() 全失效;parse 失败不缓存(下一笔重试)', () => {
|
|
83
|
+
const cache = createFileCache();
|
|
84
|
+
const dir = mkdtempSync(join(tmpdir(), 'tbox-w6b-'));
|
|
85
|
+
const a = join(dir, 'a.json');
|
|
86
|
+
const b = join(dir, 'b.json');
|
|
87
|
+
writeFileSync(a, '{"f":"a"}');
|
|
88
|
+
writeFileSync(b, '{"f":"b"}');
|
|
89
|
+
cache.read(a, JSON.parse);
|
|
90
|
+
cache.read(b, JSON.parse);
|
|
91
|
+
expect(cache.parseCount).toBe(2);
|
|
92
|
+
cache.invalidate([a]);
|
|
93
|
+
cache.read(a, JSON.parse);
|
|
94
|
+
expect(cache.parseCount).toBe(3); // a 重 parse;b 仍命中
|
|
95
|
+
cache.read(b, JSON.parse);
|
|
96
|
+
expect(cache.parseCount).toBe(3);
|
|
97
|
+
// parse 失败不缓存
|
|
98
|
+
writeFileSync(a, '{broken');
|
|
99
|
+
expect(() => cache.read(a, JSON.parse)).toThrow();
|
|
100
|
+
writeFileSync(a, '{"f":"fixed"}');
|
|
101
|
+
expect(cache.read(a, JSON.parse)).toEqual({ f: 'fixed' });
|
|
102
|
+
rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('W4 contracts 版本升级:换 fixture version → resolveContracts 重解析(新版本语义生效)', async () => {
|
|
106
|
+
const app: TempApp = createTempApp('tbox-w4-');
|
|
107
|
+
invalidateContractsResolver();
|
|
108
|
+
const store = join(app.appDir, 'store');
|
|
109
|
+
writeFakeContractsPackage(store, { version: '0.8.0', withStrict: false });
|
|
110
|
+
linkContracts(app.appDir, store, { subdir: 'apps/server' });
|
|
111
|
+
const before = await resolveContractsForApp(app.appDir);
|
|
112
|
+
expect(before.strictValidation).toBe(false);
|
|
113
|
+
// 升级:同 store 原地换 version + 补严格面(pnpm update 换 store 路径的等价模拟——候选路径 version 变化);
|
|
114
|
+
// mtime 前移防粗粒度 fs 同 tick 指纹不变(边界②)
|
|
115
|
+
writeFakeContractsPackage(store, { version: '0.9.0', withStrict: true });
|
|
116
|
+
bumpMtime(join(store, 'package.json'));
|
|
117
|
+
const after = await resolveContractsForApp(app.appDir);
|
|
118
|
+
expect(after.version).toBe('0.9.0');
|
|
119
|
+
expect(after.strictValidation).toBe(true);
|
|
120
|
+
app.dispose();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// W3(模块装卸:packages/ 增删 → walkManifests 反映)与 W7(agent 动线矩阵:外部写 integrations /
|
|
125
|
+
// packages 新增模块 / .tbox/app.json 增补 / contracts 升级 四用例)——FX-3 补全(D26 验收面挂载点)。
|
|
126
|
+
|
|
127
|
+
describe('Freshness Contract W3/W7 — agent 动线矩阵(工厂方法级;FX-3)', () => {
|
|
128
|
+
it('W3 模块装卸:packages/ 增删目录 → walkManifests 反映(目录枚举零缓存)', () => {
|
|
129
|
+
const app = createTempApp('tbox-w3-');
|
|
130
|
+
expect(walkManifests(app.appDir).modules).toHaveLength(0);
|
|
131
|
+
// 新增模块目录(module add 语义——业务包磁盘足迹)
|
|
132
|
+
mkdirSync(join(app.appDir, 'packages', 'module-new'), { recursive: true });
|
|
133
|
+
writeFileSync(
|
|
134
|
+
join(app.appDir, 'packages', 'module-new', 'tbox.module.json'),
|
|
135
|
+
JSON.stringify({ schemaVersion: 1, name: 'module-new', contributes: { services: [{ service: 'new.slot' }] } }),
|
|
136
|
+
);
|
|
137
|
+
const walk = walkManifests(app.appDir);
|
|
138
|
+
expect(walk.modules.map((m) => m.id)).toContain('module-new');
|
|
139
|
+
expect(Object.keys(walk.catalog.services)).toContain('new.slot');
|
|
140
|
+
// 删除目录 → 下一笔消失
|
|
141
|
+
rmSync(join(app.appDir, 'packages', 'module-new'), { recursive: true, force: true });
|
|
142
|
+
const after = walkManifests(app.appDir);
|
|
143
|
+
expect(after.modules.map((m) => m.id)).not.toContain('module-new');
|
|
144
|
+
expect(Object.keys(after.catalog.services)).not.toContain('new.slot');
|
|
145
|
+
app.dispose();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('W7 agent 动线矩阵四用例:外部写 integrations / packages 新增模块 / app.json 增补 / contracts 升级', async () => {
|
|
149
|
+
const app = createTempApp('tbox-w7-');
|
|
150
|
+
invalidateContractsResolver();
|
|
151
|
+
const store = join(app.appDir, 'contracts-store');
|
|
152
|
+
writeFakeContractsPackage(store, { version: '0.9.0' });
|
|
153
|
+
linkContracts(app.appDir, store, { subdir: 'apps/server' });
|
|
154
|
+
writeAppFile(app.appDir, 'config/integrations.json', JSON.stringify({ services: {} }));
|
|
155
|
+
const tk = createAppToolkit(app.appDir);
|
|
156
|
+
|
|
157
|
+
// a. 外部写 integrations.json(files API / CLI 动线)→ 下一笔读反映(stat 指纹自愈)
|
|
158
|
+
writeAppFile(
|
|
159
|
+
app.appDir,
|
|
160
|
+
'config/integrations.json',
|
|
161
|
+
JSON.stringify({ services: { 'parking.query': { provider: 'mock', instances: { 'mall-a': {} } } } }),
|
|
162
|
+
);
|
|
163
|
+
const batch1 = await tk.loadServiceResolutions();
|
|
164
|
+
expect(Object.keys(batch1.resolutions)).toContain('parking.query');
|
|
165
|
+
const detail = await tk.loadService('parking.query');
|
|
166
|
+
expect(detail.integration).toEqual({ provider: 'mock', instances: { 'mall-a': {} } });
|
|
167
|
+
|
|
168
|
+
// b. packages/ 新增模块目录(module add)→ 下一笔 loadModules/loadProviders 反映
|
|
169
|
+
mkdirSync(join(app.appDir, 'packages', 'provider-x'), { recursive: true });
|
|
170
|
+
writeFileSync(join(app.appDir, 'packages', 'provider-x', 'package.json'), JSON.stringify({ name: '@app/provider-x', version: '0.1.0' }));
|
|
171
|
+
writeFileSync(
|
|
172
|
+
join(app.appDir, 'packages', 'provider-x', 'tbox.module.json'),
|
|
173
|
+
JSON.stringify({
|
|
174
|
+
schemaVersion: 1,
|
|
175
|
+
name: 'provider-x',
|
|
176
|
+
contributes: {
|
|
177
|
+
providers: { slots: [{ service: 'parking.query', provider: 'joycity', implementation: 'joycity-parking@1', credentialType: 'joycity-c' }] },
|
|
178
|
+
services: [{ service: 'member.account' }],
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
const modules = await tk.loadModules();
|
|
183
|
+
expect(modules.modules.map((m) => m.id)).toContain('provider-x');
|
|
184
|
+
const providers = await tk.loadProviders();
|
|
185
|
+
expect(providers.providers.map((p) => p.provider)).toContain('joycity');
|
|
186
|
+
|
|
187
|
+
// c. .tbox/app.json npmModules 增补 + node_modules 新包 → catalog 聚合反映(双通道)
|
|
188
|
+
mkdirSync(join(app.appDir, '.tbox'), { recursive: true });
|
|
189
|
+
writeFileSync(
|
|
190
|
+
join(app.appDir, '.tbox', 'app.json'),
|
|
191
|
+
JSON.stringify({ templateVersion: 't', npmModules: [{ id: 'module-sdk-y', package: '@sdk/module-sdk-y', version: '0.1.0', mode: 'sdk' }] }),
|
|
192
|
+
);
|
|
193
|
+
const sdkDir = join(app.appDir, 'node_modules', '@sdk', 'module-sdk-y');
|
|
194
|
+
mkdirSync(sdkDir, { recursive: true });
|
|
195
|
+
writeFileSync(
|
|
196
|
+
join(sdkDir, 'tbox.module.json'),
|
|
197
|
+
JSON.stringify({ schemaVersion: 1, name: 'module-sdk-y', contributes: { services: [{ service: 'sdk.slot-y', title: 'Sdk 槽' }] } }),
|
|
198
|
+
);
|
|
199
|
+
const modules2 = await tk.loadModules();
|
|
200
|
+
expect(modules2.modules.map((m) => m.id)).toContain('module-sdk-y');
|
|
201
|
+
const batch2 = await tk.loadServiceResolutions();
|
|
202
|
+
expect(Object.keys(batch2.resolutions)).toContain('sdk.slot-y');
|
|
203
|
+
|
|
204
|
+
// d. contracts 升级(原地 bump——version 轻探测)→ 重解析 + 新版本语义生效
|
|
205
|
+
const pkgPath = join(store, 'package.json');
|
|
206
|
+
writeFileSync(pkgPath, readFileSync(pkgPath, 'utf8').replace('0.9.0', '0.9.5'));
|
|
207
|
+
bumpMtime(pkgPath); // mtime 前移防粗粒度 fs 同 tick 指纹不变(边界②)
|
|
208
|
+
const contracts = await tk.resolveContracts();
|
|
209
|
+
expect(contracts.version).toBe('0.9.5');
|
|
210
|
+
app.dispose();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// 目录枚举零缓存约束(D26)的机制面 sanity:fskit/file-cache 均不缓存目录列表
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
describe('目录枚举零缓存约束(机制面)', () => {
|
|
218
|
+
it('file-cache 不提供目录枚举能力(约束由 API 形状承载——readdir 每次由 walkManifests 执行)', () => {
|
|
219
|
+
const cache = createFileCache();
|
|
220
|
+
const keys = Object.keys(cache);
|
|
221
|
+
expect(keys).not.toContain('readdir');
|
|
222
|
+
expect(keys).not.toContain('listDir');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('readdirSync 直接反映目录增删(枚举零缓存的行为基线)', () => {
|
|
226
|
+
const dir = mkdtempSync(join(tmpdir(), 'tbox-enum-'));
|
|
227
|
+
expect(readdirSync(dir)).toEqual([]);
|
|
228
|
+
mkdirSync(join(dir, 'module-x'));
|
|
229
|
+
expect(readdirSync(dir)).toContain('module-x');
|
|
230
|
+
rmSync(join(dir, 'module-x'), { recursive: true });
|
|
231
|
+
expect(readdirSync(dir)).not.toContain('module-x');
|
|
232
|
+
rmSync(dir, { recursive: true, force: true });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('writeAppFile(builder 外部写模拟)落盘即 stat 可见', () => {
|
|
236
|
+
const app = createTempApp('tbox-enum2-');
|
|
237
|
+
writeAppFile(app.appDir, 'config/integrations.json', '{"v":1}');
|
|
238
|
+
const st = statSync(join(app.appDir, 'config', 'integrations.json'));
|
|
239
|
+
expect(st.size).toBeGreaterThan(0);
|
|
240
|
+
app.dispose();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, relative, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* import 前缀 guard(包内自测——随 pnpm -r test 进 verify 链,L2 前缀 guard)。
|
|
7
|
+
* 规则(技术方案 §2.6):
|
|
8
|
+
* core/** 禁 import assembly|integrations|views|app-sdk
|
|
9
|
+
* assembly/** 禁 import integrations|views|app-sdk(C3 落地即生效)
|
|
10
|
+
* integrations/** 禁 import views|app-sdk(C3 落地即生效)
|
|
11
|
+
* views/** 禁 import app-sdk(C4 落地即生效)
|
|
12
|
+
* 全包 禁 import @tbox.cn/app-sdk(值或类型——叶包铁律)
|
|
13
|
+
* contracts 仅 core/contracts-expected.ts 与 tests 允许(且限 import type)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const SRC_ROOT = join(import.meta.dirname ?? new URL('.', import.meta.url).pathname, '..', 'src');
|
|
17
|
+
|
|
18
|
+
interface Violation {
|
|
19
|
+
file: string;
|
|
20
|
+
line: number;
|
|
21
|
+
text: string;
|
|
22
|
+
rule: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function listSourceFiles(dir: string, out: string[] = []): string[] {
|
|
26
|
+
for (const entry of readdirSync(dir)) {
|
|
27
|
+
const abs = join(dir, entry);
|
|
28
|
+
if (statSync(abs).isDirectory()) listSourceFiles(abs, out);
|
|
29
|
+
else if (entry.endsWith('.ts')) out.push(abs);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function layerOf(relPath: string): string {
|
|
35
|
+
const normalized = relPath.split(sep).join('/');
|
|
36
|
+
if (normalized.startsWith('core/')) return 'core';
|
|
37
|
+
if (normalized.startsWith('assembly/')) return 'assembly';
|
|
38
|
+
if (normalized.startsWith('integrations/')) return 'integrations';
|
|
39
|
+
if (normalized.startsWith('views/')) return 'views';
|
|
40
|
+
return 'root';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 相对引用向上层时按目标文件实际所在层判定;包名引用按前缀判定 */
|
|
44
|
+
function checkFile(relPath: string, content: string): Violation[] {
|
|
45
|
+
const violations: Violation[] = [];
|
|
46
|
+
const layer = layerOf(relPath);
|
|
47
|
+
const lines = content.split('\n');
|
|
48
|
+
lines.forEach((line, i) => {
|
|
49
|
+
const num = i + 1;
|
|
50
|
+
const importMatch = /(?:^|\s)import\s+(?:type\s+)?[^;]*?from\s+['"]([^'"]+)['"]|import\s*\(\s*['"]([^'"]+)['"]\s*\)|import\s+['"]([^'"]+)['"]/.exec(
|
|
51
|
+
line,
|
|
52
|
+
);
|
|
53
|
+
if (!importMatch) return;
|
|
54
|
+
const spec = importMatch[1] ?? importMatch[2] ?? importMatch[3] ?? '';
|
|
55
|
+
const isTypeImport = /import\s+type\s/.test(line);
|
|
56
|
+
|
|
57
|
+
if (spec.includes('@tbox.cn/app-sdk')) {
|
|
58
|
+
violations.push({ file: relPath, line: num, text: line.trim(), rule: '全包禁 import app-sdk(值或类型)——叶包铁律' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (spec.includes('@tbox.cn/app-contracts')) {
|
|
62
|
+
const allowed = (layer === 'core' && relPath.replace(sep, '/').startsWith('core/contracts-expected')) || relPath.replace(sep, '/').startsWith('tests/');
|
|
63
|
+
if (!allowed) {
|
|
64
|
+
violations.push({ file: relPath, line: num, text: line.trim(), rule: 'contracts 仅 core/contracts-expected.ts 与 tests 允许' });
|
|
65
|
+
} else if (!isTypeImport) {
|
|
66
|
+
violations.push({ file: relPath, line: num, text: line.trim(), rule: 'contracts 仅限 import type(运行时全经 resolver)' });
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (spec.startsWith('.')) {
|
|
71
|
+
// 共享叶白名单:dto.ts(纯类型)/ core/errors.ts(错误类)任意层可引(零领域依赖叶)
|
|
72
|
+
const targetNorm = relPath.replace(sep, '/').split('/').slice(0, -1).join('/');
|
|
73
|
+
const resolvedSpec = spec.replace('.js', '.ts');
|
|
74
|
+
if (resolvedSpec === '../dto.ts' || resolvedSpec === '../core/errors.ts' || resolvedSpec === '../../dto.ts' || resolvedSpec === '../../core/errors.ts') {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// 相对引用:解析目标文件所在层
|
|
78
|
+
const targetAbs = join(SRC_ROOT, targetNorm, spec);
|
|
79
|
+
const targetRel = relative(SRC_ROOT, targetAbs).split(sep).join('/');
|
|
80
|
+
const targetLayer = layerOf(targetRel);
|
|
81
|
+
const order = ['core', 'assembly', 'integrations', 'views', 'root'];
|
|
82
|
+
// root(index.ts) 聚合导出恒最上层
|
|
83
|
+
const upward = (l: string): number => (l === 'root' ? order.length : order.indexOf(l));
|
|
84
|
+
if (upward(targetLayer) > upward(layer)) {
|
|
85
|
+
violations.push({
|
|
86
|
+
file: relPath,
|
|
87
|
+
line: num,
|
|
88
|
+
text: line.trim(),
|
|
89
|
+
rule: `${layer}/** 禁 import ${targetLayer}/**(import 只准向下)`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return violations;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe('import 前缀 guard(L2——包内自测)', () => {
|
|
98
|
+
const files = listSourceFiles(SRC_ROOT);
|
|
99
|
+
|
|
100
|
+
it('src 源文件非空(守卫自检阈值)', () => {
|
|
101
|
+
expect(files.length).toBeGreaterThanOrEqual(8);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('分层线性链:core→assembly→integrations→views 只准向下;全包禁 app-sdk;contracts 限 import type', () => {
|
|
105
|
+
const violations = files.flatMap((abs) => {
|
|
106
|
+
const rel = relative(SRC_ROOT, abs);
|
|
107
|
+
return checkFile(rel, readFileSync(abs, 'utf8'));
|
|
108
|
+
});
|
|
109
|
+
expect(violations).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
parseModuleDescriptor,
|
|
4
|
+
moduleDescriptorSchema,
|
|
5
|
+
SAFE_NAME_PATTERN,
|
|
6
|
+
} from '../src/core/module-schema.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* module-schema(descriptor zod 单源)迁移改造验证(C2-5 + C1 P6 归位):
|
|
10
|
+
* - demand meta additive(serviceNeedContribSchema 增 title?/description?——P6)
|
|
11
|
+
* - resource type 放宽(z.enum(RESOURCE_TYPE_IDS) → z.string()——词汇校验移谓词)
|
|
12
|
+
* - 安全钉子 H9b(provider 字符集 pattern)
|
|
13
|
+
* - 既有无 meta 素材全过(additive 等价)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const BASE_MANIFEST = {
|
|
17
|
+
schemaVersion: 1,
|
|
18
|
+
name: 'module-sample',
|
|
19
|
+
version: '0.1.0',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('module-schema(descriptor zod 单源)', () => {
|
|
23
|
+
it('P6 demand meta additive:services 元素带 title/description 通过;缺席仍合法', () => {
|
|
24
|
+
const withMeta = parseModuleDescriptor({
|
|
25
|
+
...BASE_MANIFEST,
|
|
26
|
+
contributes: {
|
|
27
|
+
services: [
|
|
28
|
+
{ service: 'parking.query', optional: false, title: '车位查询', description: '查询车位状态' },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
expect(withMeta.ok).toBe(true);
|
|
33
|
+
if (withMeta.ok) {
|
|
34
|
+
expect(withMeta.value.contributes.services[0].title).toBe('车位查询');
|
|
35
|
+
expect(withMeta.value.contributes.services[0].description).toBe('查询车位状态');
|
|
36
|
+
}
|
|
37
|
+
// 既有无 meta 素材全过(additive 等价)
|
|
38
|
+
const withoutMeta = parseModuleDescriptor({
|
|
39
|
+
...BASE_MANIFEST,
|
|
40
|
+
contributes: { services: [{ service: 'parking.query' }] },
|
|
41
|
+
});
|
|
42
|
+
expect(withoutMeta.ok).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('resource type 放宽:任意 string 通过(词汇校验移谓词经 resolver 取词表)', () => {
|
|
46
|
+
const result = parseModuleDescriptor({
|
|
47
|
+
...BASE_MANIFEST,
|
|
48
|
+
contributes: { resources: [{ id: 'kb-1', type: 'knowledge' }, { id: 'kb-2', type: 'future-type' }] },
|
|
49
|
+
});
|
|
50
|
+
expect(result.ok).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('H9b 安全钉子:provider 名含路径敌对字符 → 拒绝;合法字符集通过', () => {
|
|
54
|
+
const evil = parseModuleDescriptor({
|
|
55
|
+
...BASE_MANIFEST,
|
|
56
|
+
contributes: {
|
|
57
|
+
providers: { slots: [{ service: 'a.b', provider: '../etc', implementation: 'x@1', credentialType: '-' }] },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
expect(evil.ok).toBe(false);
|
|
61
|
+
const good = parseModuleDescriptor({
|
|
62
|
+
...BASE_MANIFEST,
|
|
63
|
+
contributes: {
|
|
64
|
+
providers: { slots: [{ service: 'a.b', provider: 'wanda', implementation: 'wanda-a@1', credentialType: '-' }] },
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
expect(good.ok).toBe(true);
|
|
68
|
+
// pattern 本体断言(stem 派生第一道防线)
|
|
69
|
+
expect(SAFE_NAME_PATTERN.test('wanda')).toBe(true);
|
|
70
|
+
expect(SAFE_NAME_PATTERN.test('my-custom-api')).toBe(true);
|
|
71
|
+
expect(SAFE_NAME_PATTERN.test('../etc')).toBe(false);
|
|
72
|
+
expect(SAFE_NAME_PATTERN.test('-lead')).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('零贡献合法(provider-custom 空包形态):contributes 全 default', () => {
|
|
76
|
+
const parsed = moduleDescriptorSchema.safeParse(BASE_MANIFEST);
|
|
77
|
+
expect(parsed.success).toBe(true);
|
|
78
|
+
if (parsed.success) {
|
|
79
|
+
expect(parsed.data.contributes.providers.slots).toEqual([]);
|
|
80
|
+
expect(parsed.data.contributes.services).toEqual([]);
|
|
81
|
+
expect(parsed.data.contributes.resources).toEqual([]);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* URL↔方法映射表断言(L1 对齐律——一端点 ↔ 一方法 1:1,改名漂移即红)。
|
|
5
|
+
* C2 骨架:映射表常量 + 自检阈值;C4 工厂落地时补全 13 行并激活断言
|
|
6
|
+
* (方法名断言依赖 factory 导出——届时 import { createAppToolkit } 逐行断言 typeof)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 13 行映射表(api.md §8 单源;C4 补全为逐行断言) */
|
|
10
|
+
export const URL_METHOD_MAP: ReadonlyArray<{ method: string; url: string }> = [
|
|
11
|
+
// ── 静态(7)──
|
|
12
|
+
{ method: 'loadModules', url: 'GET /modules' },
|
|
13
|
+
{ method: 'loadModule', url: 'GET /modules/:module' },
|
|
14
|
+
{ method: 'loadApp', url: 'GET /app' },
|
|
15
|
+
{ method: 'loadService', url: 'GET /services/:service' },
|
|
16
|
+
{ method: 'loadProviders', url: 'GET /providers' },
|
|
17
|
+
{ method: 'loadProvider', url: 'GET /providers/:provider' },
|
|
18
|
+
{ method: 'loadProviderService', url: 'GET /providers/:provider/services/:service' },
|
|
19
|
+
// ── 求值(2)──
|
|
20
|
+
{ method: 'loadServiceResolutions', url: 'GET /service-resolutions' },
|
|
21
|
+
{ method: 'loadServiceResolution', url: 'GET /service-resolutions/:service' },
|
|
22
|
+
// ── 写(4)──
|
|
23
|
+
{ method: 'writeAppIntegration', url: 'PUT /app/integration' },
|
|
24
|
+
{ method: 'writeModuleIntegration', url: 'PUT /modules/:module/integration' },
|
|
25
|
+
{ method: 'writeServiceIntegration', url: 'PUT /services/:service/integration' },
|
|
26
|
+
{ method: 'deleteServiceIntegration', url: 'DELETE /services/:service/integration' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
describe('naming-alignment(L1 映射表——C2 骨架)', () => {
|
|
30
|
+
it('映射表骨架 = 13 行(静态 7 + 求值 2 + 写 4)——计数守卫', () => {
|
|
31
|
+
expect(URL_METHOD_MAP).toHaveLength(13);
|
|
32
|
+
expect(URL_METHOD_MAP.filter((r) => r.url.startsWith('GET '))).toHaveLength(9);
|
|
33
|
+
expect(URL_METHOD_MAP.filter((r) => r.url.startsWith('PUT '))).toHaveLength(3);
|
|
34
|
+
expect(URL_METHOD_MAP.filter((r) => r.url.startsWith('DELETE '))).toHaveLength(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('命名镜像律(L3):Read = load<View>;Write = write|delete<Layer>Integration', () => {
|
|
38
|
+
for (const row of URL_METHOD_MAP) {
|
|
39
|
+
if (row.url.startsWith('GET ')) {
|
|
40
|
+
expect(row.method.startsWith('load')).toBe(true);
|
|
41
|
+
} else if (row.url.startsWith('DELETE ')) {
|
|
42
|
+
expect(row.method.startsWith('delete')).toBe(true);
|
|
43
|
+
expect(row.method.endsWith('Integration')).toBe(true);
|
|
44
|
+
} else {
|
|
45
|
+
expect(row.method.startsWith('write')).toBe(true);
|
|
46
|
+
expect(row.method.endsWith('Integration')).toBe(true);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|