-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrun.mjs
More file actions
1843 lines (1812 loc) · 119 KB
/
Copy pathrun.mjs
File metadata and controls
1843 lines (1812 loc) · 119 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// tests/run.mjs - pyproc 구조/린트 게이트. Node 전용, 의존성 0.
// WASM 런타임 진짜 검증은 브라우저에서만 가능(docs/operations/testing.md). 여기서는 브라우저
// 없이 확인 가능한 것만 본다: 공개 표면·타입, em dash 0, 상대 링크 생존, 구조 불변식.
import { readFileSync, readdirSync, statSync, existsSync, mkdtempSync, rmSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { createHash } from "node:crypto";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
let passed = 0, failed = 0;
const ok = (name) => { passed++; console.log(` PASS ${name}`); };
const bad = (name, msg) => { failed++; console.log(` FAIL ${name}: ${msg}`); };
function check(name, fn) { try { fn(); ok(name); } catch (e) { bad(name, e.message); } }
async function checkAsync(name, fn) { try { await fn(); ok(name); } catch (e) { bad(name, e.message); } }
// 재귀로 지정 확장자 파일 수집(node_modules 제외).
function collect(dir, exts, acc = []) {
for (const entry of readdirSync(dir)) {
// vendor/는 fetchEngine이 받은 서드파티 배포판(gitignore) = 우리 린트 표면이 아니다.
if (entry === "node_modules" || entry === "vendor" || entry.startsWith(".git")) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) collect(full, exts, acc);
else if (exts.some((e) => entry.endsWith(e))) acc.push(full);
}
return acc;
}
const rel = (f) => f.slice(ROOT.length + 1).replaceAll("\\", "/");
// import 절은 여러 줄에 걸칠 수 있다. 개행을 배제하면 `{ a,\n b } from "x"` 형태가 통째로
// 안 보여서 구조 게이트(참조 실존/순환/레이어) 전부가 부분맹이 된다. scripts/assetManifest.mjs의
// 같은 목적 정규식과 같은 규칙(개행 허용)으로 맞춘다.
function jsModuleRefs(file) {
const src = readFileSync(file, "utf8");
const refs = [];
const add = (kind, match) => refs.push({ kind, spec: match[1] });
for (const m of src.matchAll(/^\s*(?:import|export)\s+(?:[^'"]*?\s+from\s+)?["']([^"']+)["']/gm)) add("module", m);
for (const m of src.matchAll(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g)) add("dynamic", m);
for (const m of src.matchAll(/\bimportScripts\s*\(\s*["']([^"']+)["']\s*\)/g)) add("importScripts", m);
for (const m of src.matchAll(/new\s+URL\s*\(\s*["']([^"']+)["']\s*,\s*import\.meta\.url\s*\)/g)) add("newURL", m);
return refs;
}
function moduleTarget(file, spec) {
const clean = spec.split(/[?#]/)[0];
if (clean.startsWith("/")) return join(ROOT, clean.slice(1));
if (clean.startsWith(".")) return resolve(dirname(file), clean);
return null;
}
function srcLayerName(relPath) {
const parts = relPath.split("/");
return parts[0] === "src" ? parts[1] : null;
}
function findCycles(graph) {
const cycles = [];
const state = new Map();
const stack = [];
const visit = (node) => {
state.set(node, 1);
stack.push(node);
for (const next of graph.get(node) || []) {
if (!graph.has(next)) continue;
if (!state.has(next)) visit(next);
else if (state.get(next) === 1) cycles.push(stack.slice(stack.indexOf(next)).concat(next));
}
stack.pop();
state.set(node, 2);
};
for (const node of graph.keys()) if (!state.has(node)) visit(node);
return cycles;
}
console.log("pyproc 게이트\n");
// 1) 공개 표면: index.js가 기대 export를 내는가.
console.log("[표면]");
const api = await import(pathToFileURL(join(ROOT, "index.js")).href);
const benchArtifactContract = await import(pathToFileURL(join(ROOT, "tests", "browser", "benchArtifacts.mjs")).href);
const productConsumerCoverage = await import(pathToFileURL(join(ROOT, "tests", "browser", "productConsumerCoverage.mjs")).href);
const { runMemoryMachineStoreContract } = await import(pathToFileURL(join(ROOT, "tests", "webMachine", "contracts", "machineStoreContract.mjs")).href);
const { runContextSwapContract } = await import(pathToFileURL(join(ROOT, "tests", "webMachine", "contracts", "contextSwapContract.mjs")).href);
// porcelain 일격(state-kernel 7b) 이후 루트는 정확히 6개다: 진입 동사 2(boot,
// createWebComputer) + 부활 동사 1(open) + 진단 1(checkEnvironment) + 오류 계약 2.
// 능력 상세는 핸들(runtime 탈출구)과 subpath(history/machine/worker/assets, 강등 gpu/socket/wasi)로 산다.
const assetsApi = await import(pathToFileURL(join(ROOT, "src", "runtime", "assets.js")).href);
const coreApi = await import(pathToFileURL(join(ROOT, "src", "composition", "runtimeApi.js")).href);
const sessionApi = await import(pathToFileURL(join(ROOT, "src", "session", "session.js")).href);
const electionApi = await import(pathToFileURL(join(ROOT, "src", "session", "kernelElection.js")).href);
const procApi = await import(pathToFileURL(join(ROOT, "src", "processOs", "pyProc.js")).href);
const containerApi = await import(pathToFileURL(join(ROOT, "src", "processOs", "machineContainer.js")).href);
const jobApi = await import(pathToFileURL(join(ROOT, "src", "processOs", "jobControl.js")).href);
const reactiveApi = await import(pathToFileURL(join(ROOT, "src", "capabilities", "reactive.js")).href);
const journalApi = await import(pathToFileURL(join(ROOT, "src", "capabilities", "machineJournal.js")).href);
const jailApi = await import(pathToFileURL(join(ROOT, "src", "capabilities", "machineJail.js")).href);
const deviceFsApi = await import(pathToFileURL(join(ROOT, "src", "capabilities", "deviceFs.js")).href);
const initApi = await import(pathToFileURL(join(ROOT, "src", "capabilities", "init.js")).href);
const virtualOriginApi = await import(pathToFileURL(join(ROOT, "src", "capabilities", "virtualOrigin.js")).href);
const fileSystemApi = await import(pathToFileURL(join(ROOT, "src", "runtime", "fileSystem.js")).href);
const porcelainApi = await import(pathToFileURL(join(ROOT, "src", "machine", "composition", "pyprocMachine.js")).href);
const stateBarrel = await import(pathToFileURL(join(ROOT, "src", "state", "index.js")).href);
const ROOT_EXPORTS = [
["boot", "function"], ["open", "function"], ["createWebComputer", "function"],
["checkEnvironment", "function"], ["PyProcError", "function"], ["PYPROC_ERROR_CODES", "object"],
];
for (const [name, kind] of ROOT_EXPORTS) {
check(`export ${name}:${kind}`, () => {
if (typeof api[name] !== kind) throw new Error(`got ${typeof api[name]}`);
});
}
check("루트 표면은 정확히 한 자릿수(표류 즉시 RED)", () => {
const names = Object.keys(api).sort();
const expected = ROOT_EXPORTS.map(([n]) => n).sort();
if (names.join(",") !== expected.join(",")) throw new Error("실물: " + names.join(","));
});
// d.ts 1:1 패리티: 실물 값-export와 d.ts 값-선언이 정확히 같아야 한다(표류 전과 8건의 재발 방지).
check("루트 d.ts 값-선언 1:1 패리티", () => {
const dts = readFileSync(join(ROOT, "index.d.ts"), "utf8");
const declared = new Set();
for (const m of dts.matchAll(/^export function (\w+)/gm)) declared.add(m[1]);
for (const m of dts.matchAll(/^export class (\w+)/gm)) declared.add(m[1]);
for (const m of dts.matchAll(/^export const (\w+)/gm)) declared.add(m[1]);
for (const m of dts.matchAll(/^export \{([^}]*)\} from/gm)) {
for (const raw of m[1].split(",")) {
const token = raw.trim();
if (!token || token.startsWith("type ")) continue;
declared.add(token.split(/\s+as\s+/).pop());
}
}
const real = new Set(Object.keys(api));
for (const name of real) if (!declared.has(name)) throw new Error("d.ts에 값-선언 없음: " + name);
for (const name of declared) if (!real.has(name)) throw new Error("실물에 없는 값-선언: " + name);
});
check("PAGE_SIZE === 65536 (pyproc/history 표면)", () => {
if (coreApi.PAGE_SIZE !== 65536) throw new Error(String(coreApi.PAGE_SIZE));
});
check("asset manifest 형태 (pyproc/assets 표면)", () => {
const m = assetsApi.getPyProcAssetManifest({ baseURL: "https://example.test/pkg/" });
if (m.version !== assetsApi.PYPROC_ASSET_MANIFEST_VERSION) throw new Error("version 불일치");
if (m.packageRoot !== "https://example.test/pkg/") throw new Error("packageRoot 정규화 실패");
const relRoot = assetsApi.getPyProcAssetManifest({ baseURL: "/vendor/pyproc" });
if (relRoot.packageRoot !== "/vendor/pyproc/") throw new Error("root-relative baseURL 보존 실패");
if (!relRoot.assets[0].url.startsWith("/vendor/pyproc/src/")) throw new Error("root-relative asset URL 계산 실패");
if (!m.policy.sameOriginRequired || !m.policy.preserveRelativeImports || !m.policy.runtimePreflight) throw new Error("policy 불충분");
const roles = new Set(m.assets.map((a) => a.role));
for (const role of ["processWorker", "machineWorker", "wasiWorker", "pyprocServiceWorker"])
if (!roles.has(role)) throw new Error("role 누락: " + role);
for (const a of m.assets) {
if (!a.path.startsWith("src/")) throw new Error("src 밖 자산: " + a.path);
if (!a.url.startsWith("https://example.test/pkg/src/")) throw new Error("URL 계산 실패: " + a.url);
}
});
await checkAsync("asset integrity preflight가 graph 바이트를 검증", async () => {
const path = "src/processOs/ipc.js";
const bytes = readFileSync(join(ROOT, path));
const integrity = "sha256-" + createHash("sha256").update(bytes).digest("base64");
const manifest = { files: [{ path, url: "mem://ipc", bytes: bytes.byteLength, integrity, roles: ["processWorker"] }] };
const fetchOk = async () => ({
ok: true,
status: 200,
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
});
const r = await assetsApi.verifyPyProcAssetIntegrity(manifest, { roles: ["processWorker"], fetch: fetchOk });
if (r.verified !== 1 || r.bytes !== bytes.byteLength || r.files[0] !== path) throw new Error("검증 결과 형식 오류");
let rejected = false;
try {
await assetsApi.verifyPyProcAssetIntegrity({ files: [{ ...manifest.files[0], integrity: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }] }, { roles: ["processWorker"], fetch: fetchOk });
} catch (e) {
rejected = String(e).includes("해시 불일치");
}
if (!rejected) throw new Error("잘못된 SRI를 거부하지 않음");
});
await checkAsync("Service Worker 등록 helper가 검증한 manifest URL만 사용", async () => {
const path = "src/capabilities/pyprocSw.js";
const bytes = readFileSync(join(ROOT, path));
const integrity = "sha256-" + createHash("sha256").update(bytes).digest("base64");
const manifest = { files: [{ path, url: "/src/capabilities/pyprocSw.js", bytes: bytes.byteLength, integrity, roles: ["pyprocServiceWorker"] }] };
const calls = [];
const nav = {
serviceWorker: {
register: async (url, options) => {
calls.push({ url, options });
return { ok: true, unregister: async () => true };
},
},
};
const fetchOk = async () => ({
ok: true,
status: 200,
arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
});
const r = await assetsApi.registerPyProcServiceWorker(manifest, {
navigator: nav,
fetch: fetchOk,
cache: true,
asgi: "/pyproc/",
coreIntegrity: "/pyodide-integrity.json",
coreRequired: false,
scope: "/",
});
if (calls.length !== 1) throw new Error("register 호출 수 오류");
const u = new URL(calls[0].url, "https://example.test/");
if (u.pathname !== "/src/capabilities/pyprocSw.js") throw new Error("register 경로 오류: " + calls[0].url);
if (u.searchParams.get("cache") !== "1" || u.searchParams.get("asgi") !== "/pyproc/") throw new Error("query 오류: " + u.search);
if (u.searchParams.get("coreIntegrity") !== "/pyodide-integrity.json" || u.searchParams.get("coreRequired") !== "0") throw new Error("coreIntegrity query 오류: " + u.search);
if (calls[0].options.scope !== "/") throw new Error("scope 전달 누락");
if (r.file !== path || r.integrity.verified !== 1 || r.url !== calls[0].url) throw new Error("반환값 오류");
});
// checkEnvironment는 표준 전역만 읽어 구조화된 진단을 돌려준다(Node에서도 던지지 않는다).
check("checkEnvironment() 진단 형태", () => {
const r = api.checkEnvironment();
for (const k of ["ok", "crossOriginIsolated", "sharedArrayBuffer", "jspi"]) if (typeof r[k] !== "boolean") throw new Error(k + " 형식");
if (!Array.isArray(r.issues)) throw new Error("issues 배열 아님");
for (const it of r.issues) for (const k of ["code", "need", "why", "fix"]) if (typeof it[k] !== "string") throw new Error("issue." + k + " 형식");
});
// 자가 호스팅(engine-independence P0)의 핀 정합: fetchEngine이 받는 배포판 버전과
// DEFAULT_INDEX(배포 지점의 유일 정의처)가 같은 값이어야 한다. 버전 변경 = 릴리즈 사유.
check("자가 호스팅 핀 정합(fetchEngine == DEFAULT_INDEX == assetCatalog)", () => {
const fe = readFileSync(join(ROOT, "scripts", "fetchEngine.mjs"), "utf8");
const m = fe.match(/ENGINE_VERSION = "([^"]+)"/);
if (!m) throw new Error("scripts/fetchEngine.mjs에서 ENGINE_VERSION을 못 찾음");
const rt = readFileSync(join(ROOT, "src", "runtime", "runtime.js"), "utf8");
const idx = rt.match(/DEFAULT_INDEX = "([^"]+)"/);
if (!idx || !idx[1].includes("/v" + m[1] + "/")) throw new Error("DEFAULT_INDEX에 v" + m[1] + " 없음(핀 불일치)");
// 엔진 부팅 집합의 provenance 결합: catalog의 pyodide 자산은 정확히 DEFAULT_INDEX 밑에
// 살아야 한다. 엔진 버전을 올리면서 catalog를 안 옮기면 여기서 RED가 난다(고아 기술 금지).
const catalog = JSON.parse(readFileSync(join(ROOT, "scripts", "assetCatalog.json"), "utf8"));
const engineAssets = catalog.assets.filter((asset) => asset.componentId.startsWith("pyodide-release-"));
if (!engineAssets.length) throw new Error("assetCatalog가 엔진 부팅 집합을 기술하지 않는다");
for (const asset of engineAssets) {
if (asset.url !== idx[1] + asset.name) throw new Error(`${asset.name}: catalog url이 DEFAULT_INDEX 밖이다(${asset.url})`);
}
for (const name of ["pyodide.js", "pyodide.mjs", "pyodide.asm.mjs", "pyodide.asm.wasm", "python_stdlib.zip", "pyodide-lock.json"]) {
if (!engineAssets.some((asset) => asset.name === name)) throw new Error(`엔진 부팅 자산 미기술: ${name}`);
}
});
// 2) 능력 계약이 런타임 없이도 형태를 갖추는가(메서드 존재). 소스는 내부 모듈이다:
// 계약의 목적은 "메서드가 사라지는 회귀"의 조기 발견이고, 도달 경로(핸들/탈출구)는 브라우저 게이트가 문다.
console.log("\n[계약]");
check("porcelain 계약: PyprocMachine 어휘", () => {
const p = porcelainApi.PyprocMachine.prototype;
for (const m of ["run", "runAsync", "term", "proc"]) if (typeof p[m] !== "function") throw new Error("missing " + m);
for (const g of ["runtime", "deterministic", "fs"]) {
if (!Object.getOwnPropertyDescriptor(p, g)?.get) throw new Error("getter 없음: " + g);
}
if (typeof porcelainApi.boot !== "function" || typeof porcelainApi.open !== "function") throw new Error("boot/open 없음");
});
check("Runtime 메서드", () => {
const p = coreApi.Runtime.prototype;
for (const m of ["run", "runAsync", "install", "loadPackages", "loadPackagesFromImports", "setStdout", "setStderr", "freeze", "mountHome", "noteStateMutation", "enableReactive", "enableSyscallBridge", "enableAsgiServer", "enableVirtualOrigin", "enableTerminal", "enableWheelCache", "enableDeviceFs", "enableInit", "enableJournal"])
if (typeof p[m] !== "function") throw new Error("missing " + m);
});
check("FileSystem 메서드", () => {
for (const m of ["writeFile", "readFile", "mkdir", "mkdirTree", "readdir", "stat", "exists", "unlink", "rmdir"])
if (typeof fileSystemApi.FileSystem.prototype[m] !== "function") throw new Error("FileSystem." + m);
});
check("DeviceFs/Init 메서드", () => {
for (const m of ["install", "track", "refreshClipboard"]) if (typeof deviceFsApi.DeviceFs.prototype[m] !== "function") throw new Error("DeviceFs." + m);
for (const m of ["install", "resume", "stop"]) if (typeof initApi.Init.prototype[m] !== "function") throw new Error("Init." + m);
});
check("MachineJournal 메서드", () => {
for (const m of ["start", "stop", "commit", "pack", "prune", "recover"])
if (typeof journalApi.MachineJournal.prototype[m] !== "function") throw new Error("MachineJournal." + m);
});
check("MachineJail 메서드", () => {
for (const m of ["allows", "connectSrc", "csp", "install"])
if (typeof jailApi.MachineJail.prototype[m] !== "function") throw new Error("MachineJail." + m);
});
check("VirtualOrigin 메서드", () => {
const p = virtualOriginApi.VirtualOrigin.prototype;
for (const m of ["bind", "unbind"])
if (typeof p[m] !== "function") throw new Error("missing " + m);
});
check("PyProc 메서드", () => {
const p = procApi.PyProc.prototype;
for (const m of ["boot", "map", "mapArray", "matmul", "ps", "kill", "signal", "respawn", "fork", "forkMany", "exec", "pipe", "lock", "semaphore", "shm", "terminate", "repl"])
if (typeof p[m] !== "function") throw new Error("missing " + m);
if (procApi.PyProc.SIGNAL !== procApi.SIGNAL) throw new Error("PyProc.SIGNAL 정적 상수 누락");
});
check("MachineContainer 메서드", () => {
const p = containerApi.MachineContainer.prototype;
for (const m of ["spawn", "kill", "install", "terminate"])
if (typeof p[m] !== "function") throw new Error("missing " + m);
});
check("KernelElection 메서드", () => {
const p = electionApi.KernelElection.prototype;
for (const m of ["join", "run", "commit", "ready", "status", "subscribe", "role", "leave"])
if (typeof p[m] !== "function") throw new Error("missing " + m);
if (typeof electionApi.openPersistentMachine !== "function") throw new Error("openPersistentMachine");
});
check("JobControl 메서드", () => {
const p = jobApi.JobControl.prototype;
for (const m of ["boot", "push", "jobs", "fg", "kill", "terminate"])
if (typeof p[m] !== "function") throw new Error("missing " + m);
});
check("Session/bootSession/openMachine 계약(내부 표면, porcelain의 발밑)", () => {
for (const m of ["save", "load", "exportImage"]) if (typeof sessionApi.Session.prototype[m] !== "function") throw new Error("Session." + m);
for (const fn of ["bootSession", "openMachine"]) if (typeof sessionApi[fn] !== "function") throw new Error(fn);
});
check("SIGNAL 표(POSIX 번호)", () => {
const sig = procApi.SIGNAL;
if (sig.INT !== 2 || sig.TERM !== 15 || sig.USR1 !== 10 || sig.USR2 !== 12) throw new Error(JSON.stringify(sig));
});
check("ReactiveController 메서드", () => {
const p = reactiveApi.ReactiveController.prototype;
for (const m of ["checkpoint", "restore", "restoreLive", "collectDelta", "markDirty", "pruneTo", "dispose", "tree", "storageMB", "saveBase", "loadBase"])
if (typeof p[m] !== "function") throw new Error("missing " + m);
});
// 강등 표면(pyproc/gpu, pyproc/socket, pyproc/wasi): 루트 밖이지만 subpath 계약은 유지된다.
const gpuApi = await import(pathToFileURL(join(ROOT, "src", "capabilities", "gpuCompute.js")).href);
const socketApi = await import(pathToFileURL(join(ROOT, "src", "capabilities", "socketBridge.js")).href);
const wasiApi = await import(pathToFileURL(join(ROOT, "src", "runtime", "engines", "wasi", "wasiSession.js")).href);
check("pyproc/gpu: GpuCompute/GpuArray/GpuBridge 메서드", () => {
if (typeof gpuApi.GpuCompute.create !== "function") throw new Error("GpuCompute.create(static)");
for (const m of ["array", "destroy"]) if (typeof gpuApi.GpuCompute.prototype[m] !== "function") throw new Error("GpuCompute." + m);
for (const m of ["matmul", "map", "binary", "transpose", "reduce", "toArray", "destroy"]) if (typeof gpuApi.GpuArray.prototype[m] !== "function") throw new Error("GpuArray." + m);
for (const m of ["install", "destroy"]) if (typeof gpuApi.GpuBridge.prototype[m] !== "function") throw new Error("GpuBridge." + m);
});
check("pyproc/socket: SocketBridge 메서드", () => {
if (typeof socketApi.SocketBridge.prototype.install !== "function") throw new Error("SocketBridge.install");
});
check("pyproc/wasi: bootWasi/WasiSession 메서드", () => {
if (typeof wasiApi.bootWasi !== "function") throw new Error("bootWasi");
const p = wasiApi.WasiSession.prototype;
for (const m of ["run", "get", "set", "checkpoint", "timeTravel", "installWheel", "terminate"])
if (typeof p[m] !== "function") throw new Error("missing " + m);
});
check("pyproc/history: 커널 계약 표면", () => {
// 위 [state 커널] 절이 프로토콜 실동작을 물었다. 여기는 subpath 배럴의 형태만 잠근다.
for (const fn of ["commitState", "openState", "encodeStateBundle", "decodeStateBundle", "signStateTag", "verifyStateTag", "parseSha256Address"])
if (typeof stateBarrel[fn] !== "function") throw new Error("history." + fn);
for (const cls of ["MemoryStateStore", "OpfsStateStore"]) if (typeof stateBarrel[cls] !== "function") throw new Error("history." + cls);
if (stateBarrel.PAGE_SIZE !== 65536) throw new Error("history.PAGE_SIZE");
});
// 3) em dash(U+2014) 0 - 훅과 같은 스코프(*.md, *.js).
console.log("\n[em dash]");
const EMDASH = String.fromCharCode(0x2014); // 리터럴로 쓰면 이 게이트가 자기 자신에 걸린다
for (const f of collect(ROOT, [".md", ".js", ".mjs"], [])) {
check(`no em dash: ${rel(f)}`, () => {
if (readFileSync(f, "utf8").includes(EMDASH)) throw new Error("U+2014 발견");
});
}
// 3.1) 문서 주체 가드: 문서·주석의 주체는 나다(1인칭/주어 생략). 나를 3인칭 호칭으로
// 지칭하는 표현을 차단한다(커밋 메시지 주체 중립 규칙의 문서판, 2026-07-12 확정).
// 금칙어는 리터럴로 쓰면 이 게이트가 자기 자신에 걸리므로 조립한다.
console.log("\n[문서 주체]");
const OWNER_WORD = ["소유", "자"].join(""); // "소유" + "자"
for (const f of collect(ROOT, [".md", ".js", ".mjs"], [])) {
check(`주체 중립: ${rel(f)}`, () => {
if (readFileSync(f, "utf8").includes(OWNER_WORD)) throw new Error("3인칭 호칭 발견");
});
}
// 3.2) 네이밍 가드: camelCase는 언어 불문이다(JS 문자열 안의 파이썬 포함).
// 우리 접두(_pyproc*) 스네이크와, 우리가 정의하는 파이썬 함수명의 스네이크를 차단한다.
// 외부 기술 명칭(ASGI 키 문자열, pyodide.ffi.run_sync, API kwarg 등)은 정의가 아니라 안 걸린다.
console.log("\n[네이밍]");
for (const scope of ["src", "examples", "tests"]) {
for (const f of collect(join(ROOT, scope), [".js", ".mjs", ".html"], [])) {
check(`camelCase: ${rel(f)}`, () => {
const src = readFileSync(f, "utf8");
const bad = new Set();
for (const m of src.matchAll(/_pyproc_[a-z0-9]\w*/g)) bad.add(m[0]);
for (const m of src.matchAll(/def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/g)) {
if (/[a-z0-9]_[a-z]/.test(m[1])) bad.add("def " + m[1]);
}
if (bad.size) throw new Error("스네이크 식별자: " + [...bad].slice(0, 5).join(", "));
});
}
}
// 3.3) 성능 주장 가드: 공개 표면에 숫자 간판을 걸지 않는다(2026-07-17 확정).
// 숫자를 간판으로 걸면 그 숫자를 영원히 방어할 의무가 생기고, 그 의무가 제품 방향을
// 벤치에 종속시킨다. 실측은 계속하되(개발 원칙 4) 측정치는 mainPlan/tests 기록과
// benchmark artifact에만 산다. 스코프 밖 둘: docs/operations의 게이트 임계값은 자랑이
// 아니라 계약이고, examples/의 Speed Lab은 사용자가 자기 기계에서 직접 재는 도구다.
console.log("\n[성능 주장]");
const BRAG = [
[/\d+(?:\.\d+)?\s*(?:x|×)\s*(?:faster|speedup)/i, "속도 배수 자랑"],
[/\d+(?:\.\d+)?\s*(?:x|×)\s+median\s+speedup/i, "속도 배수 자랑"],
[/\d+(?:\.\d+)?\s*배\s*(?:빠|더\s*빠)/, "속도 배수 자랑"],
[/\d+(?:\.\d+)?\s*ms\b/, "측정치 게시"],
[/\bfastest\b|blazing|가장\s*빠른|초고속/i, "최상급 속도 주장"],
// 숫자를 artifact 링크 뒤에 숨겨도 경쟁 비교 게시는 게시다.
[/(?:WebVM|JupyterLite|marimo)[^\n]*(?:artifact|측정됨|N\/A)/i, "경쟁 비교 게시"],
];
const BRAG_SURFACE = [
join(ROOT, "README.md"), join(ROOT, "README.ko.md"), join(ROOT, "CHANGELOG.md"),
// 랜딩은 자랑 표면의 한복판이다. examples/의 나머지(Speed Lab 등)는 실측 도구라 스코프 밖.
join(ROOT, "examples", "index.html"),
...collect(join(ROOT, "docs", "product"), [".md"]),
...collect(join(ROOT, "docs", "reference"), [".md"]),
];
for (const f of BRAG_SURFACE) {
check(`숫자 자랑 0: ${rel(f)}`, () => {
const hits = [];
readFileSync(f, "utf8").split("\n").forEach((line, i) => {
for (const [re, why] of BRAG) if (re.test(line)) hits.push(`L${i + 1} ${why}`);
});
if (hits.length) throw new Error(hits.slice(0, 5).join("; "));
});
}
// 3.4) digest 법 가드(state-kernel 1단계): sha256 계산과 주소 형식 조립의 소스를 좁힌다.
// raw subtle.digest는 코어 2곳(contentDigest = 정본, generationIntegrity = machine 경계의
// 주입식 사본으로 coordinator 커널 위임 시 소멸 예정)과 pyprocSw(import 0 계약 의도 중복)만.
// "sha256:" 주소 문자열 조립도 같은 두 코어만. 나머지 파일에서 발견 = 판정/형식의 새 사본.
console.log("\n[digest 법]");
{
// 7a에서 machine의 주입식 사본(generationIntegrity의 자체 subtle/hex)이 소멸했다:
// 이제 raw digest는 정본 코어와 pyprocSw(import 0 계약의 의도 중복)에만 산다.
const DIGEST_CORE = new Set([
"src/runtime/contentDigest.js",
"src/capabilities/pyprocSw.js",
]);
const ADDRESS_CORE = new Set([
"src/runtime/contentDigest.js",
]);
const rawDigest = /\.digest\(\s*["']SHA-256["']/;
const addressBuild = /["'`]sha256:(?![0-9a-f]{64})/; // 리터럴 상수 표기(테스트 기대값)는 스코프 밖
for (const f of collect(join(ROOT, "src"), [".js"])) {
const relPath = rel(f);
const text = readFileSync(f, "utf8");
check(`digest 법: ${relPath}`, () => {
if (rawDigest.test(text) && !DIGEST_CORE.has(relPath)) throw new Error("raw subtle.digest는 digest 코어에만 산다(contentDigest 경유)");
if (addressBuild.test(text) && !ADDRESS_CORE.has(relPath)) throw new Error('"sha256:" 주소 조립은 코어에만 산다(sha256Address/parseSha256Address 경유)');
});
}
}
// 3.5) state 커널 게이트(state-kernel 2단계): 순수 집합 + ref CAS 프로토콜 음성 시험.
// 실측 원형은 tests/attempts/stateKernel(0단계 probe GREEN). 여기서는 src 실물이
// 같은 위반들을 무는지 매 커밋 확인한다(안 무는 게이트는 없는 게이트보다 나쁘다).
console.log("\n[state 커널]");
{
// 순수 집합: 커널은 브라우저 저장·전역 관심사를 모른다. backend(OPFS/IndexedDB)와 정책은
// 전부 위에서 주입된다. 이 불변식이 무너지면 통합이 결합으로 역전된다(god layer).
const PURE_STATE = ["objectModel.js", "refProtocol.js", "signedTag.js", "memoryStateStore.js"];
const BROWSER_GLOBAL = /\b(navigator|window|document|indexedDB|localStorage|sessionStorage|crossOriginIsolated)\b|globalThis\.crypto|\bfetch\s*\(/;
for (const name of PURE_STATE) {
check(`state 순수 집합: ${name} 브라우저 전역 0`, () => {
const text = readFileSync(join(ROOT, "src", "state", name), "utf8");
const hit = text.split("\n").findIndex((line) => BROWSER_GLOBAL.test(line));
if (hit >= 0) throw new Error(`L${hit + 1}: 브라우저 전역/저장 접근`);
});
}
const state = await import(pathToFileURL(join(ROOT, "src", "state", "refProtocol.js")).href);
const { MemoryStateStore } = await import(pathToFileURL(join(ROOT, "src", "state", "memoryStateStore.js")).href);
const model = await import(pathToFileURL(join(ROOT, "src", "state", "objectModel.js")).href);
const tags = await import(pathToFileURL(join(ROOT, "src", "state", "signedTag.js")).href);
const provider = globalThis.crypto;
const statePage = (fill) => new Uint8Array(1024).fill(fill);
const stateInput = (n, extra = {}) => ({
pages: [[0, statePage(n)], [1, statePage(n + 1)]],
pageSize: 1024, heapLen: 2048, sp: 64, env: { h0: "h0-real" }, ...extra,
});
await checkAsync("state 프로토콜: 정상 왕복 + dedupe", async () => {
const store = new MemoryStateStore();
await state.commitState(provider, store, stateInput(10));
const second = await state.commitState(provider, store, stateInput(10, { env: { h0: "h0-real" } }));
if (second.wrote !== 0 || second.deduped < 2) throw new Error(`같은 상태 재커밋이 dedupe되지 않음(wrote ${second.wrote})`);
const opened = await state.openState(provider, store, { expectH0: "h0-real" });
if (opened.generation !== "head" || opened.pages.get(0)[0] !== 10) throw new Error("HEAD 세대 부활 실패");
});
await checkAsync("state 프로토콜: 쓰기 순서 법(지점별 크래시에 구 HEAD 무결)", async () => {
const store = new MemoryStateStore();
await state.commitState(provider, store, stateInput(10));
const base = await state.commitState(provider, store, stateInput(20));
// 반복마다 고유 페이지라 dedupe 없이 쓰기 순서 고정: blob 2 + tree + commit + PREV + HEAD = 6지점.
for (let crashAfter = 0; crashAfter < 6; crashAfter++) {
let left = crashAfter;
const crashing = Object.create(store);
crashing.writeObject = async (a, b) => { if (--left < 0) throw new Error("CRASH"); return store.writeObject(a, b); };
crashing.writeRef = async (n, r) => { if (--left < 0) throw new Error("CRASH"); return store.writeRef(n, r); };
let crashed = false;
try { await state.commitState(provider, crashing, stateInput(100 + crashAfter * 2)); }
catch (e) { crashed = e.message === "CRASH"; }
if (!crashed) throw new Error(`지점 ${crashAfter}: 6지점 안에서 커밋 성공(쓰기 순서 가정 파손)`);
const r = await state.openState(provider, store, { expectH0: "h0-real" });
if (!r || r.pages.get(0)[0] !== 20) throw new Error(`지점 ${crashAfter}: 구 HEAD 오염`);
}
const headRef = await store.readRef("HEAD");
if (headRef.ref.commit !== base.commitAddress) throw new Error("HEAD가 크래시 잔해로 이동함");
});
await checkAsync("state 프로토콜: corruption은 PREV 후퇴, 둘 다 파손은 명시 예외", async () => {
const store = new MemoryStateStore();
await state.commitState(provider, store, stateInput(30));
const last = await state.commitState(provider, store, stateInput(40));
// HEAD 세대의 tree가 가리키는 첫 페이지 blob을 변조 -> verify-on-read 적발 -> PREV 후퇴.
const treeBytes = await store.readObject(last.treeAddress);
const tampered = model.decodeStateObject(treeBytes).pages[0][1];
store.tamperObject(tampered, statePage(99));
const fb = await state.openState(provider, store, { expectH0: "h0-real" });
if (fb.generation !== "prev" || fb.fallback !== true || fb.pages.get(0)[0] !== 30) throw new Error("PREV 후퇴 실패");
// PREV까지 지우고 HEAD를 파손시키면 첫 부팅 위장 없이 명시 예외.
store.deleteRef("PREV");
store.corruptRef("HEAD");
let code = null;
try { await state.openState(provider, store, {}); } catch (e) { code = e.code; }
if (code !== "PYPROC_STATE_CORRUPT") throw new Error(`명시 예외 아님(${code})`);
});
await checkAsync("state 프로토콜: env(h0) 불일치는 PREV 후퇴 없이 즉시 예외", async () => {
const store = new MemoryStateStore();
await state.commitState(provider, store, stateInput(50));
await state.commitState(provider, store, stateInput(60));
let code = null;
try { await state.openState(provider, store, { expectH0: "h0-other" }); } catch (e) { code = e.code; }
if (code !== "PYPROC_REPLAY_MISMATCH") throw new Error(`즉시 예외 아님(${code})`);
});
await checkAsync("state 프로토콜: stale fence 거부 + HEAD 불변", async () => {
const store = new MemoryStateStore();
const tokenA = await store.claimOwner("tabA");
await state.commitState(provider, store, stateInput(70, { fence: tokenA }));
const before = (await store.readRef("HEAD")).ref.commit;
await store.claimOwner("tabB");
let code = null;
try { await state.commitState(provider, store, stateInput(80, { fence: tokenA })); } catch (e) { code = e.code; }
if (code !== "PYPROC_STATE_FENCE_STALE") throw new Error(`fence 거부 아님(${code})`);
if ((await store.readRef("HEAD")).ref.commit !== before) throw new Error("stale fence가 HEAD를 움직임");
});
await checkAsync("machine 암호 주입: 맨 Crypto는 생성자에서 거부(코어 한 벌 강제)", async () => {
const machineBarrel = await import(pathToFileURL(join(ROOT, "src", "machine", "index.js")).href);
let commitCode = null;
try { new machineBarrel.MachineCommitCoordinator({ store: {}, cryptoProvider: globalThis.crypto, idFactory: () => "x", nowFactory: () => 1 }); }
catch (e) { commitCode = e.constructor.name; }
if (commitCode !== "TypeError") throw new Error(`commit coordinator가 맨 Crypto를 받음(${commitCode})`);
let envelopeCode = null;
try { new machineBarrel.MachineEnvelopeCoordinator({ cryptoProvider: globalThis.crypto, nowFactory: () => 1 }); }
catch (e) { envelopeCode = e.constructor.name; }
if (envelopeCode !== "TypeError") throw new Error(`envelope coordinator가 맨 Crypto를 받음(${envelopeCode})`);
// 주입 provider는 통과 + digest가 코어 주소 형식을 낸다.
const wrapped = machineBarrel.createMachineCryptoProvider(globalThis.crypto);
const digest = await wrapped.digestBytes(new Uint8Array([1, 2, 3]));
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) throw new Error(`주입 digest 형식 위반(${digest})`);
});
await checkAsync("state bundle: 왕복 + 레이아웃 문서 동기 + 변조 음성 3종", async () => {
const bundle = await import(pathToFileURL(join(ROOT, "src", "state", "bundleFormat.js")).href);
const doc = readFileSync(join(ROOT, "docs", "reference", "bundleFormat.md"), "utf8");
// 문서와 코드 상수의 동기: 매직/버전/헤더 상한이 표류하면 레이아웃 계약이 거짓이 된다.
if (!doc.includes("PYBUNDLE1")) throw new Error("문서에 매직 누락");
if (!doc.includes(`"version": ${bundle.STATE_BUNDLE_VERSION}`)) throw new Error("문서 버전 표류");
if (!doc.includes("1 MiB") || bundle.STATE_BUNDLE_HEAD_MAX_BYTES !== 1024 * 1024) throw new Error("헤더 상한 표류");
const store2 = new MemoryStateStore();
const committed = await state.commitState(provider, store2, stateInput(90));
const objects = store2.entries();
const meta2 = { manifest: "{}" };
const keyPair = await tags.createStateKeyPair(provider);
const unsigned = await bundle.stateBundleHeaderDigest(provider, { commit: committed.commitAddress, meta: meta2, objects });
const tag = await tags.signStateTag(provider, keyPair, unsigned);
const bytes = await bundle.encodeStateBundle(provider, { commit: committed.commitAddress, meta: meta2, objects, tag });
const decoded = await bundle.decodeStateBundle(provider, bytes);
if (decoded.commit !== committed.commitAddress || decoded.objects.size !== objects.length) throw new Error("왕복 불일치");
if (decoded.headerDigest !== unsigned || decoded.tag.target !== unsigned) throw new Error("unsigned 다이제스트 불일치");
const jwk = await tags.exportStatePublicKey(provider, keyPair.publicKey);
const good = await tags.verifyStateTag(provider, decoded.tag, decoded.headerDigest, { trustedPublicKeys: [jwk] });
if (!good.valid || !good.trusted) throw new Error("서명 신뢰 경로 실패");
// 변조 1: 바이트 뒤집기 -> 봉투 무결성 거부
const flipped = bytes.slice(); flipped[flipped.length - 1] ^= 0xff;
let flipCode = null;
try { await bundle.decodeStateBundle(provider, flipped); } catch (e) { flipCode = e.code; }
if (flipCode !== "PYPROC_MACHINE_INTEGRITY") throw new Error(`바이트 변조 미적발(${flipCode})`);
// 변조 2: 서명 제거 재봉투 -> 무결성은 통과하되 tag 부재(신뢰 게이트가 거부할 상태)
const stripped = await bundle.decodeStateBundle(provider, await bundle.encodeStateBundle(provider, { commit: committed.commitAddress, meta: meta2, objects, tag: null }));
if (stripped.tag !== null) throw new Error("tag 제거 실패");
// 변조 3: 다른 키 서명 -> valid하되 trusted 아님
const otherTag = await tags.signStateTag(provider, await tags.createStateKeyPair(provider), unsigned);
const other = await tags.verifyStateTag(provider, otherTag, unsigned, { trustedPublicKeys: [jwk] });
if (!other.valid || other.trusted) throw new Error("잘못된 키가 trusted로 통과");
});
await checkAsync("state 서명: signedTag 서명·검증·변조 적발", async () => {
const keyPair = await tags.createStateKeyPair(provider);
const tag = await tags.signStateTag(provider, keyPair, "sha256:" + "ab".repeat(32));
const jwk = await tags.exportStatePublicKey(provider, keyPair.publicKey);
const good = await tags.verifyStateTag(provider, tag, tag.target, { trustedPublicKeys: [jwk] });
if (!good.valid || !good.trusted) throw new Error("정상 tag 검증 실패");
const stranger = await tags.verifyStateTag(provider, tag, tag.target, { trustedPublicKeys: [] });
if (!stranger.valid || stranger.trusted) throw new Error("신뢰 목록 밖 키가 trusted로 통과");
const forged = { ...tag, target: "sha256:" + "cd".repeat(32) };
const bad = await tags.verifyStateTag(provider, forged, forged.target, { trustedPublicKeys: [jwk] });
if (bad.valid) throw new Error("target 바꿔치기가 검증을 통과");
const wrongTarget = await tags.verifyStateTag(provider, tag, "sha256:" + "ef".repeat(32), { trustedPublicKeys: [jwk] });
if (wrongTarget.valid) throw new Error("기대 target 불일치가 통과");
});
}
// 파일/폴더 이름도 camelCase다. 위 검사는 파일 "내용"의 식별자만 봐서 이름 규칙은 기계 검사가
// 0이었다. mainPlan은 kebab-case 번호 문서라 예외(dartlab 관례), 검증 데이터/픽스처도 제외.
check("파일과 폴더 이름 camelCase", () => {
const CAMEL = /^[a-z][A-Za-z0-9]*$/;
const exempt = new Set(["_done", "web-machine", "guest-pyproc", "guest-v86"]);
const bad = [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
if (entry.startsWith(".") || entry === "node_modules") continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (!exempt.has(entry) && !CAMEL.test(entry)) bad.push(`${rel(full)}/ (폴더)`);
walk(full);
continue;
}
const stem = entry.replace(/\.(js|mjs|html|css|json|d\.ts)$/, "");
if (stem === entry) continue; // 검사 대상 확장자가 아니다
if (!CAMEL.test(stem) && !exempt.has(stem)) bad.push(rel(full));
}
};
for (const scope of ["src", "scripts"]) walk(join(ROOT, scope));
if (bad.length) throw new Error("camelCase 아님: " + bad.slice(0, 8).join(", "));
});
// 3.3) 오류 계약 가드: src의 모든 오류 생성은 PyProcError다(코드 없는 Error 금지).
// 계약의 축은 message가 아니라 code이므로, 코드 없는 오류가 하나라도 생기면 소비자의
// 프로그램적 분기가 다시 문자열 매칭으로 퇴행한다. 예외: pyprocSw.js는 SW 자기충족
// 파일(모듈 import 금지 계약)이라 로컬 swError 헬퍼의 new Error 1곳만 허용한다.
console.log("\n[오류 계약]");
// machine 층은 자기 오류 계약을 갖는다(web-machine 클린 아키텍처 기록): 상태 오류 =
// WebMachineError(코드), 인자 계약 위반 = TypeError. 그래서 machine에선 TypeError를 세지 않는다.
// packages/ 시절 게이트 밖에 쌓였던 무코드 new Error 80건은 전부 코드를 얻었다(감소 전용
// 예산 80 -> 0). 무코드 오류는 이제 어느 층에서도 0이다.
for (const f of collect(join(ROOT, "src"), [".js"], [])) {
check(`PyProcError only: ${rel(f)}`, () => {
const src = readFileSync(f, "utf8");
const relPath = rel(f);
if (relPath.startsWith("src/machine/")) {
const hits = [...src.matchAll(/new (Error|RangeError|SyntaxError)\(/g)];
if (hits.length > 0) {
throw new Error(`machine 오류 계약 위반: 무코드 오류 ${hits.length}건. WebMachineError(code) 또는 TypeError(인자 계약)만`);
}
return;
}
const hits = [...src.matchAll(/new (Error|TypeError|RangeError|SyntaxError)\(/g)];
const allowed = relPath === "src/capabilities/pyprocSw.js" ? 1 : 0;
if (hits.length > allowed) throw new Error(`코드 없는 오류 생성 ${hits.length}건(허용 ${allowed})`);
});
}
check("PyProcError 코드 카탈로그 = d.ts union (삼자 일치)", () => {
const catalog = api.PYPROC_ERROR_CODES;
if (!Array.isArray(catalog) || !catalog.length) throw new Error("PYPROC_ERROR_CODES export 없음");
const dtsSrc = readFileSync(join(ROOT, "index.d.ts"), "utf8");
const unionBlock = /export type PyProcErrorCode =([\s\S]*?);/.exec(dtsSrc);
if (!unionBlock) throw new Error("index.d.ts에 PyProcErrorCode union 없음");
const dtsCodes = new Set([...unionBlock[1].matchAll(/"(PYPROC_[A-Z_]+)"/g)].map((m) => m[1]));
for (const code of catalog) if (!dtsCodes.has(code)) throw new Error(`d.ts union에 없음: ${code}`);
for (const code of dtsCodes) if (!catalog.includes(code)) throw new Error(`카탈로그에 없음: ${code}`);
});
// 3.4) 영문 API 레퍼런스 동기화: 루트 export 전수가 docs/reference/api.md에 등장해야 한다.
// index.js 헤더 주석 목록의 표류(8개 어긋난 채 방치)를 반복하지 않는 기계 장치다.
console.log("\n[API 레퍼런스]");
check("api.md가 루트 export 전수를 다룬다", () => {
const apiDoc = readFileSync(join(ROOT, "docs", "reference", "api.md"), "utf8");
const missing = Object.keys(api).filter((name) => !apiDoc.includes("`" + name));
if (missing.length) throw new Error(`api.md 누락: ${missing.join(", ")}`);
});
check("Stable 라벨 = 승격 원장 정합(근거 없는 라벨 상승 차단)", () => {
const matrix = readFileSync(join(ROOT, "docs", "consuming", "capabilityMatrix.md"), "utf8");
if (!matrix.includes("## 상태 라벨 승격 기준")) throw new Error("승격 기준 절 없음");
const ledgerStart = matrix.indexOf("### 승격 원장");
if (ledgerStart < 0) throw new Error("승격 원장 절 없음");
const ledgerBlock = matrix.slice(ledgerStart, matrix.indexOf("승격 대기 시계", ledgerStart));
const ledgerRows = [...ledgerBlock.matchAll(/^\| [^|]+ \| 20\d\d-/gm)].length;
// 능력 표의 상태 셀만 센다(라인 중간의 "| Stable |"). 승격 기준 표의 라벨 열은
// 라인 시작이라 제외된다.
const stableRows = [...matrix.matchAll(/[^\n]\| Stable \|/g)].length;
if (stableRows !== ledgerRows) throw new Error(`Stable 라벨 ${stableRows}행 != 승격 원장 ${ledgerRows}행`);
});
// 영문 비교 페이지 게이트는 제거했다(2026-07-17). 그 게이트는 경쟁 비교 게시를 강제해
// 숫자 자랑 금지 규칙과 정면으로 충돌했다. 지난 비교는 mainPlan/_done 원장에 기록으로 남는다.
check("공개 문서 인프라 존재(CHANGELOG/SECURITY/glossary)", () => {
for (const f of ["CHANGELOG.md", "SECURITY.md", join("docs", "product", "glossary.md")]) {
if (!existsSync(join(ROOT, f))) throw new Error(`${f} 없음`);
}
const changelog = readFileSync(join(ROOT, "CHANGELOG.md"), "utf8");
if (!changelog.includes("## Unreleased")) throw new Error("CHANGELOG에 Unreleased 절 없음");
});
// 3.5) 사이트 크롬: 채널(SNS) 행은 라우트마다 고정이고 정의처는 examples/siteChrome.js 하나다.
// 라우트가 늘 때 채널을 빠뜨리거나 마크업을 다시 인라인으로 복제하는 드리프트를 차단한다.
console.log("\n[사이트 크롬]");
const chromeSrc = readFileSync(join(ROOT, "examples", "siteChrome.js"), "utf8");
check("siteChrome.js가 sns-links를 정의", () => {
if (!chromeSrc.includes('customElements.define("sns-links"')) throw new Error("정의 없음");
if (!/export const channels\s*=\s*\[/.test(chromeSrc)) throw new Error("channels export 없음");
});
check("Speed Lab 반복 벤치 통계 helper 공유", () => {
const helper = readFileSync(join(ROOT, "examples", "benchStats.js"), "utf8");
const speedLab = readFileSync(join(ROOT, "examples", "speedLab.html"), "utf8");
const matmulProbe = readFileSync(join(ROOT, "tests", "attempts", "numericShard", "matmulSurfaceProbe.html"), "utf8");
for (const sym of ["percentile", "median", "summarizePairedLatencyBench", "isShardedSpeedBenchGreen", "isProcessMapBenchGreen", "summarizeLatencyBench", "isLatencyBenchGreen", "summarizeMachineResumeBench", "isMachineResumeBenchGreen", "summarizeImmortalMachineBench", "isImmortalMachineBenchGreen"]) {
if (!helper.includes(`export function ${sym}`)) throw new Error(`benchStats.${sym} 누락`);
}
if (!speedLab.includes('from "./benchStats.js"')) throw new Error("Speed Lab이 benchStats.js를 쓰지 않음");
if (!matmulProbe.includes('from "../../../examples/benchStats.js"')) throw new Error("matmulSurfaceProbe가 benchStats.js를 쓰지 않음");
});
check("속도 비교 벤치 계약 고정", () => {
const contract = readFileSync(join(ROOT, "docs", "operations", "benchmarking.md"), "utf8");
const plan = readFileSync(join(ROOT, "mainPlan", "_done", "browser-os-north-star", "06-speed-comparison.md"), "utf8");
const docsMap = readFileSync(join(ROOT, "docs", "README.md"), "utf8");
const initiativeMap = readFileSync(join(ROOT, "mainPlan", "_done", "browser-os-north-star", "README.md"), "utf8");
const speedLab = readFileSync(join(ROOT, "examples", "speedLab.html"), "utf8");
const speedBench = readFileSync(join(ROOT, "tests", "browser", "speedBench.mjs"), "utf8");
const benchArtifact = readFileSync(join(ROOT, "tests", "browser", "benchArtifact.mjs"), "utf8");
const benchArtifacts = readFileSync(join(ROOT, "tests", "browser", "benchArtifacts.mjs"), "utf8");
const benchCompare = readFileSync(join(ROOT, "tests", "browser", "benchCompare.mjs"), "utf8");
const pkgForBench = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
// 후보 이름(WebVM/JupyterLite/marimo)은 더 이상 benchmarking.md의 필수 항목이 아니다.
// 경쟁 비교는 게시물이 아니라 원장 기록이므로 mainPlan/_done 계약에서만 요구한다.
for (const term of ["S0", "S0C", "S1", "S1L", "S2", "S3", "S4", "S5", "median", "p95", "raw output"]) {
if (!contract.includes(term)) throw new Error(`benchmarking.md 필수 항목 누락: ${term}`);
if (!plan.includes(term)) throw new Error(`06-speed-comparison.md 필수 항목 누락: ${term}`);
}
for (const name of ["WebVM", "JupyterLite", "marimo"]) {
if (!plan.includes(name)) throw new Error(`06-speed-comparison.md 후보 누락: ${name}`);
}
for (const term of ["schema v2", "schemaVersion", "scenarioDefinition", "measurement", "environment", "evidence", "commit", "command", "browser", "engine", "samples", "metrics"]) {
if (!contract.includes(term)) throw new Error(`실측 봉투 필드 누락: ${term}`);
}
if (!docsMap.includes("operations/benchmarking.md")) throw new Error("docs 지도에 benchmarking.md 없음");
if (!initiativeMap.includes("06-speed-comparison.md")) throw new Error("이니셔티브 지도에 06-speed-comparison.md 없음");
if (pkgForBench.scripts?.["bench:speed"] !== "node tests/browser/speedBench.mjs") throw new Error("bench:speed 스크립트 없음");
if (pkgForBench.scripts?.["bench:artifact"] !== "node tests/browser/benchArtifact.mjs") throw new Error("bench:artifact 스크립트 없음");
if (pkgForBench.scripts?.["bench:compare"] !== "node tests/browser/benchCompare.mjs") throw new Error("bench:compare 스크립트 없음");
if (!speedLab.includes('scenario: "S1"') || !speedLab.includes("bench,")) throw new Error("Speed Lab gate report가 S1 bench JSON을 싣지 않음");
for (const term of ['readIntParam("size"', 'readIntParam("workers"', 'readIntParam("samples"']) {
if (!speedLab.includes(term)) throw new Error(`Speed Lab query 계약 누락: ${term}`);
}
for (const term of ["PYPROC_BENCH_OUT", "PYPROC_BENCH_SIZE", '"--size"', "DEFAULT_SIZE = 1024", "BENCH_ARTIFACT_SCHEMA_VERSION", "scenarioDefinition", "measurement", "environment", "evidence", "schemaVersion", 'scenario: S1_SCENARIO', 'candidate: "pyproc"', "metrics", "runner", "browserVersion", "normalizeBenchArtifact"]) {
if (!speedBench.includes(term)) throw new Error(`speedBench.mjs 필수 항목 누락: ${term}`);
}
for (const term of ["BENCH_ARTIFACT_SCHEMA_VERSION", "SCENARIO_DEFINITIONS", "scenarioDefinitionFor", "RAW_OUTPUT_EMBEDDED_REPORT", "RAW_OUTPUT_FILE_PREFIX", "rawOutputPathForArtifact", "assertV2Envelope", "sampleSchema", "measurement", "environment", "evidence", "rawOutput", "browser server roundtrip", "machine resume", "immortal multi-tab machine", "S0_SCENARIO", "S0C_SCENARIO", "S1L_SCENARIO", "S2_SCENARIO", "S3_SCENARIO", "S4_SCENARIO", "S5_SCENARIO", "SUPPORTED_SCENARIOS", "normalizeBenchArtifact", "renderBenchCompareMarkdown", "notApplicableReason", "medianSpeedup", "medianMs", "openMedianMs", "failoverP95Ms"]) {
if (!benchArtifacts.includes(term)) throw new Error(`benchArtifacts.mjs 필수 항목 누락: ${term}`);
}
for (const term of ["--candidate", "--scenario", "--sample", "--command", "--source", "--raw-output", "--raw-output-file", "--profile", "--warmup-count", "--browser-headless", "--na", "scenarioDefinition", "measurement", "environment", "evidence", "rawOutputSidecar", "summarizePairedLatencyBench", "isProcessMapBenchGreen", "summarizeLatencyBench", "parseLatencySample", "parseMachineResumeSample", "summarizeMachineResumeBench", "isMachineResumeBenchGreen", "parseImmortalMachineSample", "summarizeImmortalMachineBench", "isImmortalMachineBenchGreen", "normalizeBenchArtifact"]) {
if (!benchArtifact.includes(term)) throw new Error(`benchArtifact.mjs 필수 항목 누락: ${term}`);
}
const artifactDir = join(ROOT, "mainPlan", "_done", "browser-os-north-star", "benchmarks");
const artifactFiles = readdirSync(artifactDir).filter((name) => name.endsWith(".json")).sort();
if (!artifactFiles.length) throw new Error("benchmark JSON artifact 없음");
for (const name of artifactFiles) {
const file = join(artifactDir, name);
const raw = JSON.parse(readFileSync(file, "utf8"));
if (raw.schemaVersion !== benchArtifactContract.BENCH_ARTIFACT_SCHEMA_VERSION) throw new Error(`${name}: schemaVersion v2 아님`);
if (!raw.scenarioDefinition || !raw.measurement || !raw.environment || !raw.evidence) throw new Error(`${name}: v2 봉투 누락`);
benchArtifactContract.normalizeBenchArtifactFile(file);
const rawOutputPath = benchArtifactContract.rawOutputPathForArtifact(raw, file);
if (rawOutputPath) {
const relativeRawOutput = rel(rawOutputPath);
const tracked = spawnSync("git", ["ls-files", "--error-unmatch", relativeRawOutput], { cwd: ROOT, encoding: "utf8", timeout: 5000 });
if (tracked.status !== 0) throw new Error(`${name}: rawOutput file git 미추적: ${relativeRawOutput}`);
if (!readFileSync(rawOutputPath, "utf8").trim()) throw new Error(`${name}: rawOutput file 비어 있음`);
} else if (raw.evidence.rawOutput !== benchArtifactContract.RAW_OUTPUT_EMBEDDED_REPORT) {
throw new Error(`${name}: rawOutput reference 형식 불명`);
}
}
const productConsumer = readFileSync(join(ROOT, "tests", "browser", "productConsumer.mjs"), "utf8");
const immortalProductGate = readFileSync(join(ROOT, "tests", "browser", "immortalProductGate.js"), "utf8");
for (const term of ["machineExportMs", "machineOpenMs", "machineMB", "machineResumeRows"]) {
if (!productConsumer.includes(term)) throw new Error(`productConsumer.mjs S4 timing 누락: ${term}`);
}
for (const term of ["immortalInitialReadyMs", "immortalRpcP50Ms", "immortalRpcP90Ms", "immortalFailoverMs", "immortalRecoveryMs", "immortalColdReopenMs"]) {
if (!immortalProductGate.includes(term)) throw new Error(`immortalProductGate.js S5 timing 누락: ${term}`);
}
for (const term of ["normalizeBenchArtifactFile", "renderBenchCompareMarkdown"]) {
if (!benchCompare.includes(term)) throw new Error(`benchCompare.mjs 필수 항목 누락: ${term}`);
}
});
for (const f of collect(join(ROOT, "examples"), [".html"], [])) {
check(`채널 행 고정: ${rel(f)}`, () => {
const html = readFileSync(f, "utf8");
if (!html.includes("<sns-links></sns-links>")) throw new Error("<sns-links> 없음");
if (!/<script type="module" src="(examples\/)?siteChrome\.js"><\/script>/.test(html))
throw new Error("siteChrome.js 모듈 스크립트 없음");
if (html.includes("snsBtn")) throw new Error("채널 마크업 인라인 복제(SSOT 우회)");
});
}
// 3.6) 브랜드: 마크 정본은 assets/logo.svg 하나다. 파비콘·헤더 로고·색이 여기서만 나온다.
// 마크를 인라인으로 복제하거나(6쪽이 갈라진다), 마크와 CSS 색이 어긋나는 드리프트를 차단한다.
console.log("\n[브랜드]");
const logoSvg = readFileSync(join(ROOT, "assets", "logo.svg"), "utf8");
const cssSrc = readFileSync(join(ROOT, "examples", "demo.css"), "utf8");
const markColors = {
// 마크의 그라디언트 양 끝과 터미널 패널 색 = 브랜드 색의 출처.
markFrom: logoSvg.match(/<stop offset="0%" stop-color="(#[0-9a-f]{6})"\/>/)?.[1],
markTo: logoSvg.match(/<stop offset="100%" stop-color="(#[0-9a-f]{6})"\/>\s*<\/linearGradient>/)?.[1],
ink: logoSvg.match(/<path [^>]*fill="(#[0-9a-f]{6})"\/>/g)?.map((m) => m.match(/fill="(#[0-9a-f]{6})"/)[1])[0],
};
for (const [name, color] of Object.entries(markColors)) {
check(`demo.css --${name}이 마크 실측색(${color})과 일치`, () => {
if (!color) throw new Error("logo.svg에서 색을 못 읽음(마크 구조 변경?)");
const declared = cssSrc.match(new RegExp(`--${name}:\\s*(#[0-9a-f]{6})`))?.[1];
if (declared !== color) throw new Error(`demo.css는 ${declared}, 마크는 ${color}`);
});
}
const landing = readFileSync(join(ROOT, "examples", "index.html"), "utf8");
for (const f of collect(join(ROOT, "examples"), [".html"], [])) {
const html = readFileSync(f, "utf8");
const prefix = html === landing ? "assets/" : "../assets/"; // 랜딩만 배포 루트로 승격된다
check(`마크 참조 고정: ${rel(f)}`, () => {
if (!html.includes(`<link rel="icon" href="${prefix}logo.svg">`)) throw new Error("파비콘이 마크 정본을 안 씀");
if (!html.includes(`<img class="logoMark" src="${prefix}logo.svg"`)) throw new Error("헤더 로고가 마크 정본을 안 씀");
if (/<svg[^>]*class="logoMark"/.test(html)) throw new Error("마크 인라인 복제(SSOT 우회)");
if (/rel="icon" href="data:/.test(html)) throw new Error("파비콘 data URI 복제(SSOT 우회)");
});
}
check("pages.yml이 assets를 배포(안 그러면 파비콘·로고가 404)", () => {
const pages = readFileSync(join(ROOT, ".github", "workflows", "pages.yml"), "utf8");
if (!/cp -r [^\n]*\bassets\b/.test(pages)) throw new Error("assets 복사 없음");
});
// SVG는 XML이다: 주석 안의 연속 하이픈은 XML이 금지한다. 어기면 마크가 파싱 불가가 되어
// 브라우저가 에러 한 줄 없이 이미지를 통째로 버린다(파비콘·헤더 로고가 동시에 사라진다).
check("logo.svg 주석에 연속 하이픈 없음(XML 위반 = 마크 소멸)", () => {
for (const c of logoSvg.match(/<!--[\s\S]*?-->/g) || []) {
if (c.slice(4, -3).includes("--")) throw new Error("주석 본문에 연속 하이픈: XML 파싱 불가");
}
});
// 주석 본문에 종료 기호가 섞이면 주석이 거기서 닫히고, 뒤따르는 문장이 선택자로 먹혀
// :root 블록이 통째로 무효가 된다(색이 전부 사라지는데 에러는 없다). CSS 파서와 같은 방식으로
// (여는 기호부터 첫 종료 기호까지) 주석을 걷어낸 뒤, 코드에 종료 기호가 남으면 조기 종료다.
check("demo.css 주석 무결성(조기 종료가 시트를 무력화)", () => {
const code = cssSrc.replace(/\/\*[\s\S]*?\*\//g, "");
if (code.includes("*/")) throw new Error("주석 밖에 종료 기호가 남음: 주석 본문이 주석을 조기에 닫았다");
if (code.includes("/*")) throw new Error("닫히지 않은 주석");
});
// 이름을 바꾼 변수를 어딘가 놓치면 그 자리만 색이 사라진다(계산 시점 무효 -> 초기값). 참조는 전부 해석돼야 한다.
check("demo.css의 var(--x) 참조가 전부 선언과 짝", () => {
const declared = new Set([...cssSrc.matchAll(/(--[a-zA-Z][\w-]*)\s*:/g)].map((m) => m[1]));
const missing = [...new Set([...cssSrc.matchAll(/var\((--[\w-]+)/g)].map((m) => m[1]))].filter((v) => !declared.has(v));
if (missing.length) throw new Error("선언 없는 변수 참조: " + missing.join(", "));
});
// 4) 타입 선언: 게시되는 타입 표면이 공개 표면을 전부 덮는가.
// 루트 index.d.ts + 강등 subpath의 형제 d.ts를 함께 본다. 강등 표면은 루트에서 export되지
// 않으므로(그래서 강등이다) 자기 .js 옆의 d.ts가 유일한 타입 출처다.
console.log("\n[타입]");
const SUBPATH_DTS = [
"src/state/index.d.ts",
"src/machine/index.d.ts",
"src/runtime/assets.d.ts",
"src/capabilities/gpuCompute.d.ts",
"src/capabilities/socketBridge.d.ts",
"src/runtime/engines/wasi/wasiSession.d.ts",
];
const dts = [join(ROOT, "index.d.ts"), ...SUBPATH_DTS.map((p) => join(ROOT, p))]
.map((f) => readFileSync(f, "utf8")).join("\n");
for (const sym of ["boot", "open", "checkEnvironment"]) {
check(`d.ts가 ${sym} 선언`, () => {
if (!new RegExp(`export function ${sym}\\b`).test(dts)) throw new Error("선언 없음");
});
}
check("d.ts가 PyProcError/PYPROC_ERROR_CODES 선언", () => {
if (!/export class PyProcError/.test(dts)) throw new Error("PyProcError");
if (!/export const PYPROC_ERROR_CODES/.test(dts)) throw new Error("PYPROC_ERROR_CODES");
});
// 값-export가 아니게 된 핸들·탈출구 타입은 declare + export type으로 산다(1:1 패리티 게이트와 짝).
for (const sym of ["Runtime", "MemoryCapability", "FileSystem", "ReactiveController", "SyscallBridge", "AsgiServer", "VirtualOrigin", "Terminal", "DeviceFs", "Init", "MachineJournal", "Session", "WheelCache", "PyProc", "KernelElection", "PyprocMachine", "PyprocHistory"]) {
check(`d.ts가 ${sym} 타입 선언(declare)`, () => {
if (!new RegExp(`declare class ${sym}\\b`).test(dts)) throw new Error("declare 없음");
});
}
check("d.ts subpath 값 선언(assets/history)", () => {
const assetsDts = readFileSync(join(ROOT, "src", "runtime", "assets.d.ts"), "utf8");
for (const sym of ["getPyProcAssetManifest", "verifyPyProcAssetIntegrity", "registerPyProcServiceWorker", "PYPROC_ASSET_MANIFEST_VERSION"]) {
if (!new RegExp(`export (function|const) ${sym}\\b`).test(assetsDts)) throw new Error(`assets.d.ts: ${sym}`);
}
const stateDts = readFileSync(join(ROOT, "src", "state", "index.d.ts"), "utf8");
for (const sym of ["commitState", "openState", "encodeStateBundle", "decodeStateBundle", "PAGE_SIZE"]) {
if (!new RegExp(`export (function|const|declare)?\s*(function|const)? ?${sym}\\b`).test(stateDts) && !stateDts.includes(sym)) throw new Error(`state/index.d.ts: ${sym}`);
}
});
const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
check("package.json types -> index.d.ts", () => {
if (pkg.types !== "./index.d.ts") throw new Error(String(pkg.types));
if (pkg.exports["."].types !== "./index.d.ts") throw new Error("exports['.'].types 누락");
if (!pkg.files.includes("index.d.ts")) throw new Error("files에 index.d.ts 누락");
});
// 강등 subpath의 타입은 자기 .js 옆의 d.ts로만 성립한다. index.d.ts 안의
// `declare module "pyproc/gpu"` 블록은 이 자리를 대신하지 못했다: 모듈이 untyped .js로
// 해석되면 TypeScript가 증강을 거부한다(TS2665). 타입체크 게이트가 붙고서야 드러난 사실이라
// 위치를 계약으로 고정한다.
check("강등 subpath 타입은 자기 .js 옆에", () => {
for (const rel of SUBPATH_DTS) {
if (!existsSync(join(ROOT, rel))) throw new Error(`${rel} 없음`);
const js = rel.replace(/\.d\.ts$/, ".js");
if (!existsSync(join(ROOT, js))) throw new Error(`${js} 없음(d.ts가 짝 없이 떠 있다)`);
const target = Object.values(pkg.exports).find((t) => typeof t === "string" && t === "./" + js);
if (!target) throw new Error(`${js}가 exports subpath가 아니다`);
}
if (readFileSync(join(ROOT, "index.d.ts"), "utf8").includes('declare module "pyproc/')) {
throw new Error("index.d.ts의 declare module 블록: 형제 d.ts로 옮겨야 한다(TS2665)");
}
});
check("타입 계약 게이트 배선", () => {
if (pkg.scripts?.["test:types"] !== "npx -y -p typescript@5 tsc -p tests/tsconfig.json") throw new Error("test:types 누락");
const cfg = JSON.parse(readFileSync(join(ROOT, "tests", "tsconfig.json"), "utf8"));
// skipLibCheck는 .d.ts 검사 자체를 건너뛴다. 켜지면 게이트가 조용히 통과한다.
if (cfg.compilerOptions?.skipLibCheck !== false) throw new Error("skipLibCheck가 false가 아니다(게이트가 조용히 통과한다)");
if (cfg.compilerOptions?.strict !== true) throw new Error("strict 필요");
for (const rel of ["../index.d.ts", ...SUBPATH_DTS.map((p) => "../" + p)]) {
if (!cfg.files.includes(rel)) throw new Error(`tsconfig files에 ${rel} 누락`);
}
});
check("package.json bin -> assetManifest CLI", () => {
if (pkg.bin?.["pyproc-assets"] !== "./scripts/assetManifest.mjs") throw new Error("pyproc-assets bin 누락");
if (!pkg.files.includes("scripts/assetManifest.mjs")) throw new Error("files에 assetManifest.mjs 누락");
});
check("package.json 소비자 게이트 스크립트", () => {
if (pkg.scripts?.["test:package"] !== "node tests/packageConsumer.mjs") throw new Error("test:package 누락");
if (pkg.scripts?.["test:consumer"] !== "node tests/browser/productConsumer.mjs") throw new Error("test:consumer 누락");
});
check("d.ts가 PyProc 샤딩 옵션 계약을 선언", () => {
if (!dts.includes("export interface PyProcShardOptions extends PyProcMapOptions")) throw new Error("PyProcShardOptions 누락");
if (!dts.includes("export interface PyProcMatmulOptions extends PyProcShardOptions")) throw new Error("PyProcMatmulOptions 누락");
if (!dts.includes("mapArray(fnSrc: string, typed: ArrayBufferView, opts?: PyProcShardOptions): Promise<unknown[]>;")) throw new Error("mapArray parts 타입 누락");
if (!dts.includes("matmul(a: Matrix, b: Matrix, opts?: PyProcMatmulOptions): Promise<Matrix>;")) throw new Error("matmul parts 타입 누락");
});
check("exports 경로 실존", () => {
for (const [sub, target] of Object.entries(pkg.exports)) {
const t = typeof target === "string" ? target : target.default;
if (!existsSync(join(ROOT, t))) throw new Error(`${sub} -> ${t} 없음`);
}
});
check("exports 안정 subpath 고정", () => {
const allowed = new Set([".", "./history", "./machine", "./worker", "./assets", "./gpu", "./socket", "./wasi"]);
const keys = Object.keys(pkg.exports);
for (const key of keys) {
if (!allowed.has(key)) throw new Error(`승인 안 된 export key: ${key}`);
if (key.startsWith("./src/")) throw new Error(`src deep export 금지: ${key}`);
}
for (const key of allowed) if (!keys.includes(key)) throw new Error(`export key 누락: ${key}`);
if (pkg.exports["./history"] !== "./src/state/index.js") throw new Error("pyproc/history는 state 배럴을 가리켜야 함");
});
// 4.5) README 표면 동기화: index.js의 모든 export가 양쪽 README에 등장해야 한다.
// 승격이 문서를 앞지르는 드리프트를 차단한다(계약 실태 표의 부채 해소, 2026-07-12).
console.log("\n[README 표면]");
for (const readme of ["README.md", "README.ko.md"]) {
check(`${readme}가 공개 표면 전부 언급`, () => {
const text = readFileSync(join(ROOT, readme), "utf8");
const missing = Object.keys(api).filter((name) => !text.includes("`" + name));
if (missing.length) throw new Error(`표면 누락: ${missing.join(", ")}`);
});
}
check("README 공개 표면은 작업별 지도 형태", () => {
const readmeEn = readFileSync(join(ROOT, "README.md"), "utf8");
const readmeKo = readFileSync(join(ROOT, "README.ko.md"), "utf8");
if (!readmeEn.includes("| Need | Public exports | Runnable proof |")) throw new Error("README.md 공개 표면 지도 헤더 누락");
if (!readmeKo.includes("| 필요한 것 | 공개 export | 실행 증거 |")) throw new Error("README.ko.md 공개 표면 지도 헤더 누락");
if (readmeEn.includes("| Export | What |")) throw new Error("README.md가 장황한 export 설명표로 회귀");
if (readmeKo.includes("| Export | 무엇 |")) throw new Error("README.ko.md가 장황한 export 설명표로 회귀");
});
// 랜딩 벤치 메시지 게이트는 제거했다(2026-07-17). 이 게이트는 랜딩에 박힌 측정치(3.95x, 18ms,
// 76ms, 10.8MB ...)를 필수로 강제하고 '낡은 벤치 숫자' 목록까지 따로 관리했다. 숫자를 간판으로
// 걸면 그 숫자를 영원히 방어해야 한다는 규칙의 근거가 바로 이 게이트였다. 성능 주장 가드가 대신한다.
check("랜딩이 라이브러리 소비 판단 경로를 직접 노출", () => {
for (const term of [
'<a href="#build">Build</a>',
'<h2 id="build">Build with pyproc as a library</h2>',
"Product code should consume root exports, stable subpaths, and documented execution assets, never engine internals.",
"Public surface map",
"Capability matrix",
"Consumer contract",
"Benchmark contract",
"Pin an exact npm version for product use.",
]) {
if (!landing.includes(term)) throw new Error(`examples/index.html 라이브러리 소비 경로 누락: ${term}`);
}
for (const url of [
"https://github.com/eddmpython/pyproc#public-surface",
"https://github.com/eddmpython/pyproc/blob/main/docs/consuming/capabilityMatrix.md",
"https://github.com/eddmpython/pyproc/blob/main/docs/consuming/contract.md",
"https://github.com/eddmpython/pyproc/blob/main/docs/operations/benchmarking.md",
]) {
if (!landing.includes(`href="${url}"`)) throw new Error(`examples/index.html GitHub 문서 링크 누락: ${url}`);
}
if (/href="docs\//.test(landing)) throw new Error("Pages 배포에서 깨질 로컬 docs 링크 사용");
});
check("소비 문서 역할 분리", () => {
const contract = readFileSync(join(ROOT, "docs", "consuming", "contract.md"), "utf8");
const docsMap = readFileSync(join(ROOT, "docs", "README.md"), "utf8");
if (!contract.includes("역할은 분리한다.")) throw new Error("contract.md 역할 분리 선언 누락");
if (!contract.includes("## 공개 import 경계")) throw new Error("contract.md import 경계 절 누락");
if (!contract.includes("## 실행 자산 배포 계약")) throw new Error("contract.md 실행 자산 배포 절 누락");
if (!contract.includes("## 계약 검증")) throw new Error("contract.md 계약 검증 절 누락");
if (!contract.includes("### 설치 패키지 consumer gate coverage")) throw new Error("contract.md 설치 패키지 consumer gate coverage 절 누락");
if (!contract.includes("[capabilityMatrix.md](capabilityMatrix.md): capability별 제품 가치")) throw new Error("contract.md가 capability matrix 역할을 위임하지 않음");
if (contract.includes("| export | 무엇 |")) throw new Error("contract.md가 capability별 export 설명표로 회귀");
if (!docsMap.includes("설치, 버전 핀, import 경계, 실행 자산 배포")) throw new Error("docs/README.md contract 역할 설명이 낡음");
});
check("설치 패키지 consumer gate coverage가 실제 게이트와 정합", () => {
const contract = readFileSync(join(ROOT, "docs", "consuming", "contract.md"), "utf8");
const testing = readFileSync(join(ROOT, "docs", "operations", "testing.md"), "utf8");
const packageConsumer = readFileSync(join(ROOT, "tests", "packageConsumer.mjs"), "utf8");
const productConsumer = readFileSync(join(ROOT, "tests", "browser", "productConsumer.mjs"), "utf8");
const immortalGate = readFileSync(join(ROOT, "tests", "browser", "immortalProductGate.js"), "utf8");
const immortalParticipant = readFileSync(join(ROOT, "tests", "browser", "immortalProductParticipant.html"), "utf8");
const expectedTable = productConsumerCoverage.renderProductConsumerCoverageMarkdown();
if (!contract.includes(expectedTable)) throw new Error("contract.md consumer coverage 표가 productConsumerCoverage.mjs 렌더링과 불일치");
if (!productConsumer.includes("productConsumerCoverageManifest")) throw new Error("productConsumer.mjs가 coverage manifest SSOT를 import하지 않음");
if (!productConsumer.includes("coverageManifest")) throw new Error("productConsumer.mjs가 coverage manifest를 report하지 않음");
if (!productConsumer.includes("product consumer coverage manifest")) throw new Error("productConsumer.mjs가 coverage manifest report 검증을 출력하지 않음");