inscript-lang 2.11.0__tar.gz → 2.12.0__tar.gz
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.
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/PKG-INFO +1 -1
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/errors.py +18 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/inscript.py +64 -8
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/inscript_lang.egg-info/PKG-INFO +1 -1
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/inscript_lang.egg-info/SOURCES.txt +3 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/inscript_lang.egg-info/top_level.txt +3 -0
- inscript_lang-2.12.0/inscript_studio_api.py +295 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/pyproject.toml +2 -1
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/setup.py +1 -0
- inscript_lang-2.12.0/studio_bridge.py +239 -0
- inscript_lang-2.12.0/studio_readiness.py +408 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/README.md +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/analyzer.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/ast_nodes.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/compiler.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/environment.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/export_pipeline.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/hot_reload.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/inscript_dap.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/inscript_fmt.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/inscript_lang.egg-info/dependency_links.txt +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/inscript_lang.egg-info/entry_points.txt +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/inscript_lang.egg-info/requires.txt +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/inscript_test.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/interpreter.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/lexer.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/parser.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/pygame_backend.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/repl.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/scene_tree.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/setup.cfg +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/stdlib.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/stdlib_assets.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/stdlib_extended.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/stdlib_extended_2.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/stdlib_game.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/stdlib_values.py +0 -0
- {inscript_lang-2.11.0 → inscript_lang-2.12.0}/vm.py +0 -0
|
@@ -85,6 +85,24 @@ class InScriptError(Exception):
|
|
|
85
85
|
self.code = code or ERROR_CODES.get(cls_name, "E0000")
|
|
86
86
|
super().__init__(self._format())
|
|
87
87
|
|
|
88
|
+
def to_dict(self) -> dict:
|
|
89
|
+
"""v2.12.0: Structured dict for --json-errors / Studio error panel."""
|
|
90
|
+
return {
|
|
91
|
+
"type": "error",
|
|
92
|
+
"code": self.code,
|
|
93
|
+
"class": type(self).__name__,
|
|
94
|
+
"message": self.message,
|
|
95
|
+
"line": self.line,
|
|
96
|
+
"col": self.col,
|
|
97
|
+
"source_line": self.source_line,
|
|
98
|
+
"hint": self.hint,
|
|
99
|
+
"call_trace": self.call_trace,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def to_json(self) -> str:
|
|
103
|
+
import json as _j
|
|
104
|
+
return _j.dumps(self.to_dict(), separators=(",", ":"))
|
|
105
|
+
|
|
88
106
|
def _format(self) -> str:
|
|
89
107
|
code = self.code
|
|
90
108
|
cls = type(self).__name__
|
|
@@ -24,7 +24,7 @@ from errors import (InScriptError, LexerError, ParseError,
|
|
|
24
24
|
SemanticError, InScriptRuntimeError,
|
|
25
25
|
MultiError, InScriptWarning)
|
|
26
26
|
|
|
27
|
-
VERSION = "2.
|
|
27
|
+
VERSION = "2.12.0"
|
|
28
28
|
|
|
29
29
|
MANIFEST_FILENAME = "inscript.toml"
|
|
30
30
|
LOCK_FILENAME = "inscript.lock"
|
|
@@ -2385,21 +2385,34 @@ def run_source(source: str, filename: str = "<stdin>",
|
|
|
2385
2385
|
no_warn_unused: bool = False,
|
|
2386
2386
|
warn_as_error: bool = False,
|
|
2387
2387
|
strict: bool = False,
|
|
2388
|
-
profile: bool = False
|
|
2388
|
+
profile: bool = False,
|
|
2389
|
+
json_errors: bool = False) -> int:
|
|
2389
2390
|
"""Lex, parse, analyze, and interpret InScript source code."""
|
|
2391
|
+
|
|
2392
|
+
def _emit_error(e):
|
|
2393
|
+
"""v2.12.0: emit as JSON or plain text depending on flag."""
|
|
2394
|
+
if json_errors:
|
|
2395
|
+
from inscript_studio_api import error_stream
|
|
2396
|
+
from errors import InScriptError as _ISE
|
|
2397
|
+
if isinstance(e, _ISE):
|
|
2398
|
+
error_stream([e])
|
|
2399
|
+
else:
|
|
2400
|
+
error_stream([{"type": "error", "code": "E0000",
|
|
2401
|
+
"class": type(e).__name__, "message": str(e),
|
|
2402
|
+
"line": 0, "col": 0}])
|
|
2403
|
+
else:
|
|
2404
|
+
print(e, file=sys.stderr)
|
|
2390
2405
|
# ── 1. Lex ────────────────────────────────────────────────────────────
|
|
2391
2406
|
try:
|
|
2392
2407
|
tokens = tokenize(source, filename)
|
|
2393
2408
|
except LexerError as e:
|
|
2394
|
-
|
|
2395
|
-
return 1
|
|
2409
|
+
_emit_error(e); return 1
|
|
2396
2410
|
|
|
2397
2411
|
# ── 2. Parse ──────────────────────────────────────────────────────────
|
|
2398
2412
|
try:
|
|
2399
2413
|
program = parse(source, filename)
|
|
2400
2414
|
except ParseError as e:
|
|
2401
|
-
|
|
2402
|
-
return 1
|
|
2415
|
+
_emit_error(e); return 1
|
|
2403
2416
|
|
|
2404
2417
|
# ── 3. Analyze (optional) — Phase 3: multi-error + warnings ──────────
|
|
2405
2418
|
if type_check:
|
|
@@ -2442,7 +2455,7 @@ def run_source(source: str, filename: str = "<stdin>",
|
|
|
2442
2455
|
interp.run(program)
|
|
2443
2456
|
return 0
|
|
2444
2457
|
except InScriptError as e:
|
|
2445
|
-
|
|
2458
|
+
_emit_error(e)
|
|
2446
2459
|
return 1
|
|
2447
2460
|
except KeyboardInterrupt:
|
|
2448
2461
|
print("\n[InScript] Interrupted.", file=sys.stderr)
|
|
@@ -2728,6 +2741,17 @@ Examples:
|
|
|
2728
2741
|
help="v2.11.0: Project root for --build (default: current dir)")
|
|
2729
2742
|
parser.add_argument("--new", metavar="NAME",
|
|
2730
2743
|
help="v2.11.0: Scaffold a new InScript project: inscript --new mygame")
|
|
2744
|
+
# v2.12.0: studio readiness
|
|
2745
|
+
parser.add_argument("--json-errors", action="store_true",
|
|
2746
|
+
help="v2.12.0: Output errors as newline-delimited JSON for IDE panels")
|
|
2747
|
+
parser.add_argument("--inspect", metavar="DIR", nargs="?", const=".",
|
|
2748
|
+
help="v2.12.0: Project introspection → JSON (scenes, nodes, assets, fns)")
|
|
2749
|
+
parser.add_argument("--check-v3", action="store_true",
|
|
2750
|
+
help="v2.12.0: Run v3.0.0 readiness gates; exits 0 if all pass")
|
|
2751
|
+
parser.add_argument("--studio-bridge", action="store_true",
|
|
2752
|
+
help="v2.12.0: Start Electron bridge JSON-RPC server")
|
|
2753
|
+
parser.add_argument("--bridge-port", type=int, default=8765,
|
|
2754
|
+
help="v2.12.0: Port for --studio-bridge (default: 8765)")
|
|
2731
2755
|
parser.add_argument("--lsp", action="store_true",
|
|
2732
2756
|
help="Start the Language Server (requires: pip install pygls)")
|
|
2733
2757
|
parser.add_argument("--game", action="store_true",
|
|
@@ -3012,6 +3036,37 @@ Examples:
|
|
|
3012
3036
|
except Exception as _bld:
|
|
3013
3037
|
print(f"[build] {_bld}", file=sys.stderr); return 1
|
|
3014
3038
|
|
|
3039
|
+
# ── v2.12.0 studio readiness ───────────────────────────────────────────
|
|
3040
|
+
if getattr(args, 'check_v3', False):
|
|
3041
|
+
from studio_readiness import run_all_gates
|
|
3042
|
+
base = getattr(args, 'project_dir', '.') or '.'
|
|
3043
|
+
_, all_passed = run_all_gates(base_dir=base)
|
|
3044
|
+
return 0 if all_passed else 1
|
|
3045
|
+
|
|
3046
|
+
if getattr(args, 'inspect', None) is not None:
|
|
3047
|
+
try:
|
|
3048
|
+
from inscript_studio_api import project_inspect
|
|
3049
|
+
import json as _j
|
|
3050
|
+
data = project_inspect(args.inspect)
|
|
3051
|
+
print(_j.dumps(data, indent=2))
|
|
3052
|
+
except Exception as _ie:
|
|
3053
|
+
print(f"[inspect] {_ie}", file=sys.stderr); return 1
|
|
3054
|
+
return 0
|
|
3055
|
+
|
|
3056
|
+
if getattr(args, 'studio_bridge', False):
|
|
3057
|
+
from studio_bridge import StudioBridge
|
|
3058
|
+
import time as _time
|
|
3059
|
+
port = getattr(args, 'bridge_port', 8765)
|
|
3060
|
+
bridge = StudioBridge(port=port)
|
|
3061
|
+
bridge.start()
|
|
3062
|
+
print(f"[studio-bridge] Listening on {bridge.url}")
|
|
3063
|
+
print(f"[studio-bridge] Ctrl+C to stop")
|
|
3064
|
+
try:
|
|
3065
|
+
while True: _time.sleep(1)
|
|
3066
|
+
except KeyboardInterrupt:
|
|
3067
|
+
bridge.stop()
|
|
3068
|
+
return 0
|
|
3069
|
+
|
|
3015
3070
|
# v1.9.1: --no-typecheck is deprecated — emit a warning and honour it
|
|
3016
3071
|
if getattr(args, 'no_typecheck', False):
|
|
3017
3072
|
print(
|
|
@@ -3097,7 +3152,8 @@ Examples:
|
|
|
3097
3152
|
return run_source(_source, filename=args.file, type_check=type_check,
|
|
3098
3153
|
no_warn=no_warn, no_warn_unused=no_warn_unused,
|
|
3099
3154
|
warn_as_error=warn_as_error, strict=strict,
|
|
3100
|
-
profile=profile
|
|
3155
|
+
profile=profile,
|
|
3156
|
+
json_errors=getattr(args, 'json_errors', False))
|
|
3101
3157
|
|
|
3102
3158
|
|
|
3103
3159
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -9,6 +9,7 @@ hot_reload.py
|
|
|
9
9
|
inscript.py
|
|
10
10
|
inscript_dap.py
|
|
11
11
|
inscript_fmt.py
|
|
12
|
+
inscript_studio_api.py
|
|
12
13
|
inscript_test.py
|
|
13
14
|
interpreter.py
|
|
14
15
|
lexer.py
|
|
@@ -24,6 +25,8 @@ stdlib_extended.py
|
|
|
24
25
|
stdlib_extended_2.py
|
|
25
26
|
stdlib_game.py
|
|
26
27
|
stdlib_values.py
|
|
28
|
+
studio_bridge.py
|
|
29
|
+
studio_readiness.py
|
|
27
30
|
vm.py
|
|
28
31
|
inscript_lang.egg-info/PKG-INFO
|
|
29
32
|
inscript_lang.egg-info/SOURCES.txt
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
inscript_studio_api.py — InScript v2.12.0 Studio Plugin API
|
|
4
|
+
================================================================
|
|
5
|
+
Stable extension points that InScript Studio (Electron) and third-party
|
|
6
|
+
plugins use to integrate with the runtime.
|
|
7
|
+
|
|
8
|
+
StudioPlugin — base class for plugins; override hook methods
|
|
9
|
+
StudioAPI — registry; emit events to all registered plugins
|
|
10
|
+
project_inspect — static analysis → JSON scene/node/fn/asset list
|
|
11
|
+
error_stream — format errors as newline-delimited JSON for the IDE panel
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
import os, re, json
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
# Plugin base class
|
|
21
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
class StudioPlugin:
|
|
24
|
+
"""
|
|
25
|
+
Base class for Studio plugins. Override any hook you care about.
|
|
26
|
+
|
|
27
|
+
Plugins are registered with the global StudioAPI singleton:
|
|
28
|
+
from inscript_studio_api import get_api
|
|
29
|
+
get_api().register(MyPlugin())
|
|
30
|
+
"""
|
|
31
|
+
name: str = "unnamed_plugin"
|
|
32
|
+
|
|
33
|
+
def on_scene_save(self, scene_path: str, tree) -> None:
|
|
34
|
+
"""Called when a .inscene file is saved from the editor."""
|
|
35
|
+
|
|
36
|
+
def on_asset_import(self, handle) -> None:
|
|
37
|
+
"""Called when an AssetHandle is registered in the AssetRegistry."""
|
|
38
|
+
|
|
39
|
+
def on_error(self, error: dict) -> None:
|
|
40
|
+
"""Called when the interpreter raises an InScriptError.
|
|
41
|
+
error is a dict matching InScriptError.to_dict()."""
|
|
42
|
+
|
|
43
|
+
def on_breakpoint(self, file: str, line: int, env: dict) -> None:
|
|
44
|
+
"""Called when a breakpoint is hit (DAP integration)."""
|
|
45
|
+
|
|
46
|
+
def on_reload(self, result) -> None:
|
|
47
|
+
"""Called after every successful hot reload."""
|
|
48
|
+
|
|
49
|
+
def on_build(self, target: str, output_dir: str, success: bool) -> None:
|
|
50
|
+
"""Called after inscript build completes."""
|
|
51
|
+
|
|
52
|
+
def __repr__(self):
|
|
53
|
+
return f"<StudioPlugin {self.name}>"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
# StudioAPI registry
|
|
58
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
class StudioAPI:
|
|
61
|
+
"""
|
|
62
|
+
Central registry for Studio plugins. Emits lifecycle events to all
|
|
63
|
+
registered plugins. Singleton — use get_api() rather than instantiating.
|
|
64
|
+
"""
|
|
65
|
+
def __init__(self):
|
|
66
|
+
self._plugins: List[StudioPlugin] = []
|
|
67
|
+
|
|
68
|
+
def register(self, plugin: StudioPlugin) -> "StudioAPI":
|
|
69
|
+
if plugin not in self._plugins:
|
|
70
|
+
self._plugins.append(plugin)
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def unregister(self, plugin: StudioPlugin) -> bool:
|
|
74
|
+
if plugin in self._plugins:
|
|
75
|
+
self._plugins.remove(plugin)
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
def plugin_count(self) -> int:
|
|
80
|
+
return len(self._plugins)
|
|
81
|
+
|
|
82
|
+
def emit(self, event: str, *args, **kwargs) -> List[Any]:
|
|
83
|
+
"""
|
|
84
|
+
Emit an event to all registered plugins.
|
|
85
|
+
Returns list of return values (Nones filtered out).
|
|
86
|
+
"""
|
|
87
|
+
results = []
|
|
88
|
+
for plugin in list(self._plugins):
|
|
89
|
+
handler = getattr(plugin, event, None)
|
|
90
|
+
if handler is None:
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
rv = handler(*args, **kwargs)
|
|
94
|
+
if rv is not None:
|
|
95
|
+
results.append(rv)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
print(f"[StudioAPI] Plugin '{plugin.name}' error in {event}: {e}")
|
|
98
|
+
return results
|
|
99
|
+
|
|
100
|
+
# Convenience emitters matching StudioPlugin hooks
|
|
101
|
+
def emit_scene_save(self, scene_path: str, tree) -> None:
|
|
102
|
+
self.emit("on_scene_save", scene_path, tree)
|
|
103
|
+
|
|
104
|
+
def emit_asset_import(self, handle) -> None:
|
|
105
|
+
self.emit("on_asset_import", handle)
|
|
106
|
+
|
|
107
|
+
def emit_error(self, error: dict) -> None:
|
|
108
|
+
self.emit("on_error", error)
|
|
109
|
+
|
|
110
|
+
def emit_breakpoint(self, file: str, line: int, env: dict) -> None:
|
|
111
|
+
self.emit("on_breakpoint", file, line, env)
|
|
112
|
+
|
|
113
|
+
def emit_reload(self, result) -> None:
|
|
114
|
+
self.emit("on_reload", result)
|
|
115
|
+
|
|
116
|
+
def emit_build(self, target: str, output_dir: str, success: bool) -> None:
|
|
117
|
+
self.emit("on_build", target, output_dir, success)
|
|
118
|
+
|
|
119
|
+
def __repr__(self):
|
|
120
|
+
return f"<StudioAPI plugins={len(self._plugins)}>"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Module-level singleton
|
|
124
|
+
_GLOBAL_API = StudioAPI()
|
|
125
|
+
|
|
126
|
+
def get_api() -> StudioAPI:
|
|
127
|
+
return _GLOBAL_API
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
131
|
+
# Project introspection — static analysis without running the interpreter
|
|
132
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
# Patterns for extracting declarations from .ins source (fast, no full parse)
|
|
135
|
+
_RE_NODE = re.compile(r'^\s*node\s+(\w+)\s*\{', re.MULTILINE)
|
|
136
|
+
_RE_SCENE = re.compile(r'^\s*scene\s+(\w+)\s*\{', re.MULTILINE)
|
|
137
|
+
_RE_STRUCT = re.compile(r'^\s*struct\s+(\w+)\s*\{', re.MULTILINE)
|
|
138
|
+
_RE_FN = re.compile(r'^\s*(?:export\s+)?fn\s+(\w+)\s*\(', re.MULTILINE)
|
|
139
|
+
_RE_TEXTURE = re.compile(r'@texture\s*\(\s*["\']([^"\']+)["\']', re.MULTILINE)
|
|
140
|
+
_RE_SOUND = re.compile(r'@sound\s*\(\s*["\']([^"\']+)["\']', re.MULTILINE)
|
|
141
|
+
_RE_TILEMAP = re.compile(r'@tilemap\s*\(\s*["\']([^"\']+)["\']', re.MULTILINE)
|
|
142
|
+
_RE_FONT = re.compile(r'@font\s*\(\s*["\']([^"\']+)["\']', re.MULTILINE)
|
|
143
|
+
_RE_IMPORT = re.compile(r'^\s*import\s+["\']([^"\']+)["\']', re.MULTILINE)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _scan_ins_file(file_path: str) -> dict:
|
|
147
|
+
"""
|
|
148
|
+
Scan a single .ins file and return a summary dict:
|
|
149
|
+
{file, nodes, scenes, structs, functions, assets, imports}
|
|
150
|
+
"""
|
|
151
|
+
try:
|
|
152
|
+
with open(file_path, encoding="utf-8") as f:
|
|
153
|
+
src = f.read()
|
|
154
|
+
except OSError:
|
|
155
|
+
return {}
|
|
156
|
+
|
|
157
|
+
rel = file_path # caller normalises
|
|
158
|
+
|
|
159
|
+
assets = []
|
|
160
|
+
for kind, pat in (("texture", _RE_TEXTURE), ("sound", _RE_SOUND),
|
|
161
|
+
("tilemap", _RE_TILEMAP), ("font", _RE_FONT)):
|
|
162
|
+
for m in pat.finditer(src):
|
|
163
|
+
assets.append({"type": kind, "path": m.group(1)})
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
"file": rel,
|
|
167
|
+
"nodes": [m.group(1) for m in _RE_NODE.finditer(src)],
|
|
168
|
+
"scenes": [m.group(1) for m in _RE_SCENE.finditer(src)],
|
|
169
|
+
"structs": [m.group(1) for m in _RE_STRUCT.finditer(src)],
|
|
170
|
+
"functions": [m.group(1) for m in _RE_FN.finditer(src)],
|
|
171
|
+
"assets": assets,
|
|
172
|
+
"imports": [m.group(1) for m in _RE_IMPORT.finditer(src)],
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _scan_inscene_file(file_path: str) -> Optional[dict]:
|
|
177
|
+
"""Read a .inscene file and return {file, scene_name, root, nodes}."""
|
|
178
|
+
try:
|
|
179
|
+
with open(file_path, encoding="utf-8") as f:
|
|
180
|
+
content = f.read()
|
|
181
|
+
except OSError:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
name_m = re.search(r'^name\s*=\s*"([^"]+)"', content, re.MULTILINE)
|
|
185
|
+
root_m = re.search(r'^root\s*=\s*"([^"]+)"', content, re.MULTILINE)
|
|
186
|
+
nodes = re.findall(r'^type\s*=\s*"([^"]+)"', content, re.MULTILINE)
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
"file": file_path,
|
|
190
|
+
"scene_name": name_m.group(1) if name_m else "",
|
|
191
|
+
"root": root_m.group(1) if root_m else "",
|
|
192
|
+
"nodes": nodes,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def project_inspect(project_dir: str = ".") -> dict:
|
|
197
|
+
"""
|
|
198
|
+
Statically analyse a project directory and return a JSON-serialisable
|
|
199
|
+
summary of all declarations. Used by the Studio scene panel, node
|
|
200
|
+
inspector, and asset browser.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
{
|
|
204
|
+
"project_dir": str,
|
|
205
|
+
"version": str, # inscript.VERSION
|
|
206
|
+
"sources": [...], # per-file scan results
|
|
207
|
+
"scenes": [...], # merged list of scene names
|
|
208
|
+
"nodes": [...], # merged list of node names
|
|
209
|
+
"structs": [...],
|
|
210
|
+
"functions": [...],
|
|
211
|
+
"assets": [...],
|
|
212
|
+
"scene_files": [...], # .inscene file summaries
|
|
213
|
+
"imports": [...],
|
|
214
|
+
}
|
|
215
|
+
"""
|
|
216
|
+
from inscript import VERSION
|
|
217
|
+
|
|
218
|
+
project_dir = os.path.abspath(project_dir)
|
|
219
|
+
sources = []
|
|
220
|
+
scene_files = []
|
|
221
|
+
|
|
222
|
+
all_scenes = []
|
|
223
|
+
all_nodes = []
|
|
224
|
+
all_structs = []
|
|
225
|
+
all_functions = []
|
|
226
|
+
all_assets = []
|
|
227
|
+
all_imports = set()
|
|
228
|
+
|
|
229
|
+
# Walk the project for .ins and .inscene files
|
|
230
|
+
for root, dirs, files in os.walk(project_dir):
|
|
231
|
+
# Skip build output and hidden dirs
|
|
232
|
+
dirs[:] = [d for d in dirs
|
|
233
|
+
if d not in ("build", "__pycache__", "inscript_runtime")
|
|
234
|
+
and not d.startswith(".")]
|
|
235
|
+
for fn in sorted(files):
|
|
236
|
+
full = os.path.join(root, fn)
|
|
237
|
+
rel = os.path.relpath(full, project_dir)
|
|
238
|
+
if fn.endswith(".ins"):
|
|
239
|
+
scan = _scan_ins_file(full)
|
|
240
|
+
scan["file"] = rel
|
|
241
|
+
sources.append(scan)
|
|
242
|
+
all_scenes += [{"name": n, "file": rel} for n in scan["nodes"]]
|
|
243
|
+
all_nodes += [{"name": n, "file": rel} for n in scan["nodes"]]
|
|
244
|
+
all_structs += [{"name": n, "file": rel} for n in scan["structs"]]
|
|
245
|
+
all_functions += [{"name": n, "file": rel} for n in scan["functions"]]
|
|
246
|
+
all_assets += [dict(a, file=rel) for a in scan["assets"]]
|
|
247
|
+
all_imports |= set(scan["imports"])
|
|
248
|
+
elif fn.endswith(".inscene"):
|
|
249
|
+
sf = _scan_inscene_file(full)
|
|
250
|
+
if sf:
|
|
251
|
+
sf["file"] = rel
|
|
252
|
+
scene_files.append(sf)
|
|
253
|
+
all_scenes.append({"name": sf["scene_name"], "file": rel,
|
|
254
|
+
"root": sf["root"]})
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
"project_dir": project_dir,
|
|
258
|
+
"version": VERSION,
|
|
259
|
+
"sources": sources,
|
|
260
|
+
"scenes": all_scenes,
|
|
261
|
+
"nodes": all_nodes,
|
|
262
|
+
"structs": all_structs,
|
|
263
|
+
"functions": all_functions,
|
|
264
|
+
"assets": all_assets,
|
|
265
|
+
"scene_files": scene_files,
|
|
266
|
+
"imports": sorted(all_imports),
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
271
|
+
# Error stream — newline-delimited JSON for IDE panels
|
|
272
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
def error_stream(errors: list, file=None) -> None:
|
|
275
|
+
"""
|
|
276
|
+
Write errors as newline-delimited JSON to `file` (default: sys.stderr).
|
|
277
|
+
Each line is a self-contained JSON object.
|
|
278
|
+
|
|
279
|
+
Format:
|
|
280
|
+
{"type":"error","code":"E0042","message":"...","line":5,"col":3}
|
|
281
|
+
{"type":"error","code":"E0001","message":"...","line":12,"col":1}
|
|
282
|
+
"""
|
|
283
|
+
import sys as _sys
|
|
284
|
+
out = file or _sys.stderr
|
|
285
|
+
for err in errors:
|
|
286
|
+
if hasattr(err, "to_dict"):
|
|
287
|
+
d = err.to_dict()
|
|
288
|
+
elif isinstance(err, dict):
|
|
289
|
+
d = err
|
|
290
|
+
else:
|
|
291
|
+
d = {"type": "error", "code": "E0000",
|
|
292
|
+
"class": type(err).__name__, "message": str(err),
|
|
293
|
+
"line": 0, "col": 0}
|
|
294
|
+
out.write(json.dumps(d, separators=(",", ":")) + "\n")
|
|
295
|
+
out.flush()
|
|
@@ -61,7 +61,8 @@ py-modules = [
|
|
|
61
61
|
# ── Backends ──────────────────────────────────────────────────────────────
|
|
62
62
|
"pygame_backend",
|
|
63
63
|
# ── v2.7.0+ runtime modules ───────────────────────────────────────────────
|
|
64
|
-
"scene_tree", "hot_reload", "export_pipeline"
|
|
64
|
+
"scene_tree", "hot_reload", "export_pipeline",
|
|
65
|
+
"studio_bridge", "inscript_studio_api", "studio_readiness"
|
|
65
66
|
]
|
|
66
67
|
|
|
67
68
|
[tool.setuptools.package-data]
|
|
@@ -56,6 +56,7 @@ setup(
|
|
|
56
56
|
"pygame_backend",
|
|
57
57
|
# ── v2.7.0+ runtime modules ───────────────────────────────────────────
|
|
58
58
|
"scene_tree", "hot_reload", "export_pipeline",
|
|
59
|
+
"studio_bridge", "inscript_studio_api", "studio_readiness",
|
|
59
60
|
],
|
|
60
61
|
package_data = {"": ["examples/*.ins", "lsp/*.py", "*.md"]},
|
|
61
62
|
install_requires = [],
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
studio_bridge.py — InScript v2.12.0 Electron Bridge
|
|
4
|
+
================================================================
|
|
5
|
+
A lightweight JSON-RPC HTTP server that the InScript Studio Electron
|
|
6
|
+
shell calls to drive the runtime.
|
|
7
|
+
|
|
8
|
+
Start from CLI:
|
|
9
|
+
inscript --studio-bridge [--bridge-port 8765]
|
|
10
|
+
|
|
11
|
+
Or from Python:
|
|
12
|
+
from studio_bridge import StudioBridge
|
|
13
|
+
bridge = StudioBridge(port=8765)
|
|
14
|
+
bridge.start() # non-blocking daemon thread
|
|
15
|
+
bridge.stop()
|
|
16
|
+
|
|
17
|
+
JSON-RPC protocol (HTTP POST to /rpc):
|
|
18
|
+
Request: { "method": "run", "id": 1, "params": {"file": "src/main.ins"} }
|
|
19
|
+
Response: { "result": "ok", "id": 1 }
|
|
20
|
+
Error: { "error": "...", "id": 1 }
|
|
21
|
+
|
|
22
|
+
Methods
|
|
23
|
+
───────
|
|
24
|
+
run — execute a .ins file; returns {"status": "ok"|"error", "output": [...]}
|
|
25
|
+
stop — stop a running interpreter
|
|
26
|
+
hot_reload — trigger hot reload on a file
|
|
27
|
+
scene_list — return all scene/node declarations in project
|
|
28
|
+
inspect — full project introspection (scenes, nodes, fns, assets)
|
|
29
|
+
eval — evaluate an InScript expression in live env
|
|
30
|
+
ping — health check; returns {"pong": true}
|
|
31
|
+
version — returns {"version": "2.12.0"}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
import json, threading, sys, os, io
|
|
36
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
37
|
+
from typing import Any, Dict, Optional
|
|
38
|
+
|
|
39
|
+
DEFAULT_PORT = 8765
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _RpcHandler(BaseHTTPRequestHandler):
|
|
43
|
+
"""HTTP handler — all requests are POST to /rpc."""
|
|
44
|
+
|
|
45
|
+
def log_message(self, fmt, *args):
|
|
46
|
+
pass # suppress default access log
|
|
47
|
+
|
|
48
|
+
def do_POST(self):
|
|
49
|
+
if self.path != "/rpc":
|
|
50
|
+
self._reply(404, {"error": "Not found — POST to /rpc"})
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
54
|
+
body = self.rfile.read(length)
|
|
55
|
+
request = json.loads(body)
|
|
56
|
+
except Exception as e:
|
|
57
|
+
self._reply(400, {"error": f"Bad JSON: {e}", "id": None})
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
req_id = request.get("id")
|
|
61
|
+
method = request.get("method", "")
|
|
62
|
+
params = request.get("params", {})
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
result = self.server._bridge._dispatch(method, params)
|
|
66
|
+
self._reply(200, {"result": result, "id": req_id})
|
|
67
|
+
except Exception as e:
|
|
68
|
+
self._reply(200, {"error": str(e), "id": req_id})
|
|
69
|
+
|
|
70
|
+
def _reply(self, status: int, data: dict):
|
|
71
|
+
body = json.dumps(data, default=str).encode()
|
|
72
|
+
self.send_response(status)
|
|
73
|
+
self.send_header("Content-Type", "application/json")
|
|
74
|
+
self.send_header("Content-Length", str(len(body)))
|
|
75
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
76
|
+
self.end_headers()
|
|
77
|
+
self.wfile.write(body)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class StudioBridge:
|
|
81
|
+
"""
|
|
82
|
+
Lightweight JSON-RPC bridge between the InScript runtime and
|
|
83
|
+
the Electron Studio shell.
|
|
84
|
+
"""
|
|
85
|
+
def __init__(self, port: int = DEFAULT_PORT):
|
|
86
|
+
self._port = port
|
|
87
|
+
self._server: Optional[HTTPServer] = None
|
|
88
|
+
self._thread: Optional[threading.Thread] = None
|
|
89
|
+
self._interp = None # live Interpreter (created on first run)
|
|
90
|
+
self._lock = threading.Lock()
|
|
91
|
+
self._running = False
|
|
92
|
+
|
|
93
|
+
# ── lifecycle ──────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
def start(self) -> "StudioBridge":
|
|
96
|
+
"""Start the HTTP server in a daemon thread. Non-blocking."""
|
|
97
|
+
self._server = HTTPServer(("localhost", self._port), _RpcHandler)
|
|
98
|
+
self._server._bridge = self
|
|
99
|
+
self._running = True
|
|
100
|
+
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
|
|
101
|
+
self._thread.start()
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def stop(self):
|
|
105
|
+
"""Shut down the HTTP server."""
|
|
106
|
+
self._running = False
|
|
107
|
+
if self._server:
|
|
108
|
+
self._server.shutdown()
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def port(self) -> int:
|
|
112
|
+
return self._port
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def url(self) -> str:
|
|
116
|
+
return f"http://localhost:{self._port}/rpc"
|
|
117
|
+
|
|
118
|
+
# ── RPC dispatch ──────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def _dispatch(self, method: str, params: dict) -> Any:
|
|
121
|
+
handlers = {
|
|
122
|
+
"ping": self._rpc_ping,
|
|
123
|
+
"version": self._rpc_version,
|
|
124
|
+
"run": self._rpc_run,
|
|
125
|
+
"stop": self._rpc_stop,
|
|
126
|
+
"hot_reload": self._rpc_hot_reload,
|
|
127
|
+
"scene_list": self._rpc_scene_list,
|
|
128
|
+
"inspect": self._rpc_inspect,
|
|
129
|
+
"eval": self._rpc_eval,
|
|
130
|
+
}
|
|
131
|
+
handler = handlers.get(method)
|
|
132
|
+
if handler is None:
|
|
133
|
+
raise ValueError(f"Unknown method '{method}'. Available: {sorted(handlers)}")
|
|
134
|
+
return handler(params)
|
|
135
|
+
|
|
136
|
+
# ── RPC handlers ──────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
def _rpc_ping(self, _params: dict) -> dict:
|
|
139
|
+
return {"pong": True, "port": self._port}
|
|
140
|
+
|
|
141
|
+
def _rpc_version(self, _params: dict) -> dict:
|
|
142
|
+
from inscript import VERSION
|
|
143
|
+
return {"version": VERSION, "bridge": "1.0"}
|
|
144
|
+
|
|
145
|
+
def _rpc_run(self, params: dict) -> dict:
|
|
146
|
+
"""Run a .ins file and return captured output + errors."""
|
|
147
|
+
file_path = params.get("file", "")
|
|
148
|
+
if not file_path:
|
|
149
|
+
raise ValueError("'file' parameter required")
|
|
150
|
+
if not os.path.isfile(file_path):
|
|
151
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
152
|
+
|
|
153
|
+
with open(file_path, encoding="utf-8") as f:
|
|
154
|
+
source = f.read()
|
|
155
|
+
|
|
156
|
+
output_lines: list = []
|
|
157
|
+
errors: list = []
|
|
158
|
+
|
|
159
|
+
# Redirect stdout
|
|
160
|
+
old_stdout = sys.stdout
|
|
161
|
+
sys.stdout = io.StringIO()
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
from interpreter import Interpreter
|
|
165
|
+
from errors import InScriptError
|
|
166
|
+
with self._lock:
|
|
167
|
+
self._interp = Interpreter()
|
|
168
|
+
self._interp.execute(source)
|
|
169
|
+
output_lines = sys.stdout.getvalue().splitlines()
|
|
170
|
+
except Exception as e:
|
|
171
|
+
from errors import InScriptError
|
|
172
|
+
if isinstance(e, InScriptError):
|
|
173
|
+
errors.append(e.to_dict())
|
|
174
|
+
else:
|
|
175
|
+
errors.append({"type": "error", "code": "E0000",
|
|
176
|
+
"class": type(e).__name__, "message": str(e),
|
|
177
|
+
"line": 0, "col": 0})
|
|
178
|
+
output_lines = sys.stdout.getvalue().splitlines()
|
|
179
|
+
finally:
|
|
180
|
+
sys.stdout = old_stdout
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
"status": "error" if errors else "ok",
|
|
184
|
+
"output": output_lines,
|
|
185
|
+
"errors": errors,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
def _rpc_stop(self, _params: dict) -> dict:
|
|
189
|
+
with self._lock:
|
|
190
|
+
self._interp = None
|
|
191
|
+
return {"stopped": True}
|
|
192
|
+
|
|
193
|
+
def _rpc_hot_reload(self, params: dict) -> dict:
|
|
194
|
+
"""Trigger a hot reload on a running file."""
|
|
195
|
+
file_path = params.get("file", "")
|
|
196
|
+
if not file_path:
|
|
197
|
+
raise ValueError("'file' parameter required")
|
|
198
|
+
with self._lock:
|
|
199
|
+
if self._interp is None:
|
|
200
|
+
raise RuntimeError("No running interpreter — call 'run' first")
|
|
201
|
+
from hot_reload import HotReloader
|
|
202
|
+
reloader = HotReloader(file_path, self._interp)
|
|
203
|
+
result = reloader.tick()
|
|
204
|
+
return {
|
|
205
|
+
"changed": result.changed,
|
|
206
|
+
"success": result.success,
|
|
207
|
+
"patched": result.patched,
|
|
208
|
+
"restored": len(result.restored),
|
|
209
|
+
"errors": result.errors,
|
|
210
|
+
"elapsed_ms": result.elapsed_ms,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
def _rpc_scene_list(self, params: dict) -> dict:
|
|
214
|
+
"""Return all scene and node declarations in a project directory."""
|
|
215
|
+
project_dir = params.get("dir", ".")
|
|
216
|
+
from inscript_studio_api import project_inspect
|
|
217
|
+
data = project_inspect(project_dir)
|
|
218
|
+
return {"scenes": data["scenes"], "nodes": data["nodes"]}
|
|
219
|
+
|
|
220
|
+
def _rpc_inspect(self, params: dict) -> dict:
|
|
221
|
+
"""Full project introspection."""
|
|
222
|
+
project_dir = params.get("dir", ".")
|
|
223
|
+
from inscript_studio_api import project_inspect
|
|
224
|
+
return project_inspect(project_dir)
|
|
225
|
+
|
|
226
|
+
def _rpc_eval(self, params: dict) -> dict:
|
|
227
|
+
"""Evaluate an InScript expression in the live interpreter environment."""
|
|
228
|
+
expr = params.get("expr", "")
|
|
229
|
+
if not expr:
|
|
230
|
+
raise ValueError("'expr' parameter required")
|
|
231
|
+
with self._lock:
|
|
232
|
+
if self._interp is None:
|
|
233
|
+
raise RuntimeError("No running interpreter — call 'run' first")
|
|
234
|
+
result = self._interp.execute(expr)
|
|
235
|
+
return {"result": result, "type": type(result).__name__}
|
|
236
|
+
|
|
237
|
+
def __repr__(self):
|
|
238
|
+
status = "running" if self._running else "stopped"
|
|
239
|
+
return f"<StudioBridge port={self._port} {status}>"
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
studio_readiness.py — InScript v2.12.0 Studio Readiness Gate
|
|
4
|
+
================================================================
|
|
5
|
+
`inscript --check-v3` runs all seven readiness gates and exits 0
|
|
6
|
+
only when every gate passes. This is the final checkpoint before
|
|
7
|
+
v3.0.0 InScript Studio development can safely begin.
|
|
8
|
+
|
|
9
|
+
Gates
|
|
10
|
+
─────
|
|
11
|
+
G1 60fps performance gate — 1000 NodeInstance._update calls in < 33ms
|
|
12
|
+
G2 Memory gate — < 2MB growth over 5000 game-loop iterations
|
|
13
|
+
G3 Language stability audit — all CI test files found and importable
|
|
14
|
+
G4 Error JSON stream — InScriptError.to_dict() / to_json() work
|
|
15
|
+
G5 Electron bridge — StudioBridge starts + responds to ping
|
|
16
|
+
G6 Plugin API — StudioAPI register/emit round-trip
|
|
17
|
+
G7 Project introspection — project_inspect returns well-formed JSON
|
|
18
|
+
|
|
19
|
+
If all pass, prints a v3.0.0 green-light banner.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
import sys, os, time, tracemalloc, json, threading
|
|
24
|
+
from typing import List, Tuple
|
|
25
|
+
|
|
26
|
+
GATE_COUNT = 7
|
|
27
|
+
|
|
28
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
# GateResult
|
|
30
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
class GateResult:
|
|
33
|
+
def __init__(self, gate_id: int, name: str, passed: bool,
|
|
34
|
+
detail: str = "", elapsed_ms: float = 0.0):
|
|
35
|
+
self.gate_id = gate_id
|
|
36
|
+
self.name = name
|
|
37
|
+
self.passed = passed
|
|
38
|
+
self.detail = detail
|
|
39
|
+
self.elapsed_ms = elapsed_ms
|
|
40
|
+
|
|
41
|
+
def __repr__(self):
|
|
42
|
+
icon = "✅" if self.passed else "❌"
|
|
43
|
+
ms = f" ({self.elapsed_ms:.1f}ms)" if self.elapsed_ms else ""
|
|
44
|
+
return f" {icon} G{self.gate_id}: {self.name}{ms} {self.detail}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
# G1 — 60fps performance gate
|
|
49
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
# Threshold: 1000 NodeInstance._update calls must complete in under 33ms
|
|
52
|
+
# (= 30fps minimum; any modern machine will be well above 60fps)
|
|
53
|
+
PERF_THRESHOLD_MS = 33.0
|
|
54
|
+
PERF_NODE_COUNT = 1000
|
|
55
|
+
|
|
56
|
+
def gate_performance() -> GateResult:
|
|
57
|
+
"""
|
|
58
|
+
Instantiate PERF_NODE_COUNT NodeInstances each with an _update(dt) hook
|
|
59
|
+
and measure how long one full update pass takes.
|
|
60
|
+
"""
|
|
61
|
+
t0 = time.perf_counter()
|
|
62
|
+
try:
|
|
63
|
+
from interpreter import Interpreter
|
|
64
|
+
from scene_tree import NodeBlueprint, NodeInstance
|
|
65
|
+
|
|
66
|
+
src = "node PerfNode { let c: int = 0\n_update(dt) { c = c + 1 } }"
|
|
67
|
+
interp = Interpreter()
|
|
68
|
+
interp.execute(src)
|
|
69
|
+
bp = interp._globals._store.get("PerfNode")
|
|
70
|
+
if bp is None:
|
|
71
|
+
return GateResult(1, "60fps performance gate", False,
|
|
72
|
+
"PerfNode blueprint not found", 0.0)
|
|
73
|
+
|
|
74
|
+
# Create PERF_NODE_COUNT instances
|
|
75
|
+
instances = [bp.instantiate(f"n{i}") for i in range(PERF_NODE_COUNT)]
|
|
76
|
+
|
|
77
|
+
# Time one full update pass
|
|
78
|
+
t_start = time.perf_counter()
|
|
79
|
+
for inst in instances:
|
|
80
|
+
inst.call_update(0.016)
|
|
81
|
+
t_end = time.perf_counter()
|
|
82
|
+
elapsed = (t_end - t_start) * 1000
|
|
83
|
+
|
|
84
|
+
fps_equiv = 1000.0 / elapsed if elapsed > 0 else float("inf")
|
|
85
|
+
detail = (f"{PERF_NODE_COUNT} nodes updated in {elapsed:.1f}ms "
|
|
86
|
+
f"(≈{fps_equiv:.0f}fps equiv)")
|
|
87
|
+
passed = elapsed < PERF_THRESHOLD_MS
|
|
88
|
+
if not passed:
|
|
89
|
+
detail += f" — SLOW (threshold {PERF_THRESHOLD_MS}ms)"
|
|
90
|
+
return GateResult(1, "60fps performance gate", passed, detail,
|
|
91
|
+
(time.perf_counter() - t0) * 1000)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
return GateResult(1, "60fps performance gate", False, str(e),
|
|
94
|
+
(time.perf_counter() - t0) * 1000)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
# G2 — Memory gate
|
|
99
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
MEMORY_ITERATIONS = 5000
|
|
102
|
+
MEMORY_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MB
|
|
103
|
+
|
|
104
|
+
def gate_memory() -> GateResult:
|
|
105
|
+
"""
|
|
106
|
+
Run MEMORY_ITERATIONS of a minimal game loop and measure growth via tracemalloc.
|
|
107
|
+
A leaky interpreter would show unbounded growth.
|
|
108
|
+
"""
|
|
109
|
+
t0 = time.perf_counter()
|
|
110
|
+
try:
|
|
111
|
+
from interpreter import Interpreter
|
|
112
|
+
|
|
113
|
+
interp = Interpreter()
|
|
114
|
+
interp.execute("let tick: int = 0\nfn update(dt) { tick = tick + 1 }")
|
|
115
|
+
update_fn = interp._globals._store.get("update")
|
|
116
|
+
|
|
117
|
+
tracemalloc.start()
|
|
118
|
+
snapshot_before = tracemalloc.take_snapshot()
|
|
119
|
+
|
|
120
|
+
for _ in range(MEMORY_ITERATIONS):
|
|
121
|
+
interp._call_fn(update_fn, [0.016])
|
|
122
|
+
|
|
123
|
+
snapshot_after = tracemalloc.take_snapshot()
|
|
124
|
+
tracemalloc.stop()
|
|
125
|
+
|
|
126
|
+
stats = snapshot_after.compare_to(snapshot_before, "lineno")
|
|
127
|
+
growth = sum(s.size_diff for s in stats if s.size_diff > 0)
|
|
128
|
+
|
|
129
|
+
detail = f"{growth / 1024:.1f}KB growth over {MEMORY_ITERATIONS} iterations"
|
|
130
|
+
passed = growth < MEMORY_LIMIT_BYTES
|
|
131
|
+
if not passed:
|
|
132
|
+
detail += f" — LEAK (limit {MEMORY_LIMIT_BYTES // 1024}KB)"
|
|
133
|
+
return GateResult(2, "Memory gate", passed, detail,
|
|
134
|
+
(time.perf_counter() - t0) * 1000)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
return GateResult(2, "Memory gate", False, str(e),
|
|
137
|
+
(time.perf_counter() - t0) * 1000)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
141
|
+
# G3 — Language stability audit
|
|
142
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
REQUIRED_TEST_FILES = [
|
|
145
|
+
"test_v260.py", "test_v270.py", "test_v280.py",
|
|
146
|
+
"test_v290.py", "test_v2100.py", "test_v2110.py",
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
def gate_stability(base_dir: str = ".") -> GateResult:
|
|
150
|
+
"""
|
|
151
|
+
Verify that all required CI test files exist and are importable.
|
|
152
|
+
Full execution is handled by the publish.yml workflow.
|
|
153
|
+
"""
|
|
154
|
+
t0 = time.perf_counter()
|
|
155
|
+
missing = []
|
|
156
|
+
for fn in REQUIRED_TEST_FILES:
|
|
157
|
+
if not os.path.isfile(os.path.join(base_dir, fn)):
|
|
158
|
+
missing.append(fn)
|
|
159
|
+
|
|
160
|
+
if missing:
|
|
161
|
+
detail = f"Missing test files: {missing}"
|
|
162
|
+
return GateResult(3, "Language stability audit", False, detail,
|
|
163
|
+
(time.perf_counter() - t0) * 1000)
|
|
164
|
+
|
|
165
|
+
# Quick import check
|
|
166
|
+
import_errors = []
|
|
167
|
+
for fn in ["lexer", "parser", "interpreter", "scene_tree",
|
|
168
|
+
"hot_reload", "stdlib_assets", "export_pipeline",
|
|
169
|
+
"studio_bridge", "inscript_studio_api"]:
|
|
170
|
+
try:
|
|
171
|
+
__import__(fn)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
import_errors.append(f"{fn}: {e}")
|
|
174
|
+
|
|
175
|
+
passed = not missing and not import_errors
|
|
176
|
+
detail = (f"All {len(REQUIRED_TEST_FILES)} test files present; "
|
|
177
|
+
f"{len(import_errors)} import error(s)")
|
|
178
|
+
if import_errors:
|
|
179
|
+
detail += f": {import_errors}"
|
|
180
|
+
return GateResult(3, "Language stability audit", passed, detail,
|
|
181
|
+
(time.perf_counter() - t0) * 1000)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
185
|
+
# G4 — Error JSON stream
|
|
186
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
def gate_error_json() -> GateResult:
|
|
189
|
+
"""Verify InScriptError.to_dict() and to_json() produce valid JSON."""
|
|
190
|
+
t0 = time.perf_counter()
|
|
191
|
+
try:
|
|
192
|
+
from errors import NameError_ as _NE
|
|
193
|
+
|
|
194
|
+
# Raise a real InScript error and check serialisation
|
|
195
|
+
err = _NE("Test variable not found", line=5, col=3,
|
|
196
|
+
source_line="let x = undefined_var", hint="Did you mean 'x'?")
|
|
197
|
+
d = err.to_dict()
|
|
198
|
+
|
|
199
|
+
# Validate required fields
|
|
200
|
+
required = {"type", "code", "class", "message", "line", "col"}
|
|
201
|
+
missing = required - set(d.keys())
|
|
202
|
+
if missing:
|
|
203
|
+
return GateResult(4, "Error JSON stream", False,
|
|
204
|
+
f"Missing keys: {missing}",
|
|
205
|
+
(time.perf_counter() - t0) * 1000)
|
|
206
|
+
|
|
207
|
+
# Validate to_json() is valid JSON
|
|
208
|
+
raw = err.to_json()
|
|
209
|
+
reparsed = json.loads(raw)
|
|
210
|
+
|
|
211
|
+
checks = [
|
|
212
|
+
d["type"] == "error",
|
|
213
|
+
d["line"] == 5,
|
|
214
|
+
d["col"] == 3,
|
|
215
|
+
d["hint"] == "Did you mean 'x'?",
|
|
216
|
+
reparsed["code"] == d["code"],
|
|
217
|
+
]
|
|
218
|
+
passed = all(checks)
|
|
219
|
+
detail = f"to_dict() has {len(d)} keys; to_json() round-trips OK"
|
|
220
|
+
return GateResult(4, "Error JSON stream", passed, detail,
|
|
221
|
+
(time.perf_counter() - t0) * 1000)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
return GateResult(4, "Error JSON stream", False, str(e),
|
|
224
|
+
(time.perf_counter() - t0) * 1000)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
228
|
+
# G5 — Electron bridge
|
|
229
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
BRIDGE_TEST_PORT = 18999
|
|
232
|
+
|
|
233
|
+
def gate_bridge() -> GateResult:
|
|
234
|
+
"""Start StudioBridge on a test port and verify ping round-trip."""
|
|
235
|
+
t0 = time.perf_counter()
|
|
236
|
+
try:
|
|
237
|
+
import urllib.request as _ur
|
|
238
|
+
from studio_bridge import StudioBridge
|
|
239
|
+
|
|
240
|
+
bridge = StudioBridge(port=BRIDGE_TEST_PORT)
|
|
241
|
+
bridge.start()
|
|
242
|
+
time.sleep(0.1) # allow server thread to start
|
|
243
|
+
|
|
244
|
+
req = _ur.Request(
|
|
245
|
+
bridge.url,
|
|
246
|
+
data=json.dumps({"method": "ping", "id": 1}).encode(),
|
|
247
|
+
headers={"Content-Type": "application/json"},
|
|
248
|
+
)
|
|
249
|
+
resp = _ur.urlopen(req, timeout=3)
|
|
250
|
+
body = json.loads(resp.read().decode())
|
|
251
|
+
bridge.stop()
|
|
252
|
+
|
|
253
|
+
passed = body.get("result", {}).get("pong") is True
|
|
254
|
+
detail = f"Ping response: {body.get('result')}"
|
|
255
|
+
return GateResult(5, "Electron bridge (ping)", passed, detail,
|
|
256
|
+
(time.perf_counter() - t0) * 1000)
|
|
257
|
+
except Exception as e:
|
|
258
|
+
return GateResult(5, "Electron bridge (ping)", False, str(e),
|
|
259
|
+
(time.perf_counter() - t0) * 1000)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
263
|
+
# G6 — Plugin API
|
|
264
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
def gate_plugin_api() -> GateResult:
|
|
267
|
+
"""Register a plugin, emit events, verify it received them."""
|
|
268
|
+
t0 = time.perf_counter()
|
|
269
|
+
try:
|
|
270
|
+
from inscript_studio_api import StudioAPI, StudioPlugin
|
|
271
|
+
|
|
272
|
+
received: list = []
|
|
273
|
+
|
|
274
|
+
class _TestPlugin(StudioPlugin):
|
|
275
|
+
name = "test_plugin"
|
|
276
|
+
def on_error(self, error): received.append(("error", error))
|
|
277
|
+
def on_reload(self, result): received.append(("reload", result))
|
|
278
|
+
def on_build(self, t, d, s): received.append(("build", t, s))
|
|
279
|
+
|
|
280
|
+
api = StudioAPI()
|
|
281
|
+
p = _TestPlugin()
|
|
282
|
+
api.register(p)
|
|
283
|
+
|
|
284
|
+
api.emit_error({"code": "E0042", "message": "test"})
|
|
285
|
+
api.emit_reload({"success": True})
|
|
286
|
+
api.emit_build("desktop", "/out", True)
|
|
287
|
+
|
|
288
|
+
checks = [
|
|
289
|
+
api.plugin_count() == 1,
|
|
290
|
+
len(received) == 3,
|
|
291
|
+
received[0][0] == "error",
|
|
292
|
+
received[1][0] == "reload",
|
|
293
|
+
received[2][0] == "build",
|
|
294
|
+
]
|
|
295
|
+
api.unregister(p)
|
|
296
|
+
checks.append(api.plugin_count() == 0)
|
|
297
|
+
|
|
298
|
+
passed = all(checks)
|
|
299
|
+
detail = f"3/3 events received; register/unregister OK"
|
|
300
|
+
return GateResult(6, "Plugin API", passed, detail,
|
|
301
|
+
(time.perf_counter() - t0) * 1000)
|
|
302
|
+
except Exception as e:
|
|
303
|
+
return GateResult(6, "Plugin API", False, str(e),
|
|
304
|
+
(time.perf_counter() - t0) * 1000)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
308
|
+
# G7 — Project introspection
|
|
309
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
def gate_introspection(base_dir: str = ".") -> GateResult:
|
|
312
|
+
"""Run project_inspect on a minimal temp project and verify JSON output."""
|
|
313
|
+
t0 = time.perf_counter()
|
|
314
|
+
try:
|
|
315
|
+
import tempfile, shutil
|
|
316
|
+
from inscript_studio_api import project_inspect
|
|
317
|
+
|
|
318
|
+
tmp = tempfile.mkdtemp(prefix="inscript_gate7_")
|
|
319
|
+
try:
|
|
320
|
+
os.makedirs(os.path.join(tmp, "src"))
|
|
321
|
+
os.makedirs(os.path.join(tmp, "scenes"))
|
|
322
|
+
with open(os.path.join(tmp, "src", "main.ins"), "w") as f:
|
|
323
|
+
f.write('node Player { let hp: int = 100\n_update(dt) { } }\n'
|
|
324
|
+
'fn spawn(x, y) { }\n'
|
|
325
|
+
'@texture("sprites/hero.png")\nlet hero_tex\n')
|
|
326
|
+
with open(os.path.join(tmp, "scenes", "level1.inscene"), "w") as f:
|
|
327
|
+
f.write('[scene]\nname = "level1"\nroot = "Player"\n')
|
|
328
|
+
|
|
329
|
+
data = project_inspect(tmp)
|
|
330
|
+
|
|
331
|
+
# Validate structure
|
|
332
|
+
required_keys = {"project_dir", "version", "sources", "scenes",
|
|
333
|
+
"nodes", "functions", "assets", "scene_files"}
|
|
334
|
+
missing = required_keys - set(data.keys())
|
|
335
|
+
if missing:
|
|
336
|
+
return GateResult(7, "Project introspection", False,
|
|
337
|
+
f"Missing keys: {missing}",
|
|
338
|
+
(time.perf_counter() - t0) * 1000)
|
|
339
|
+
|
|
340
|
+
# Validate JSON-serialisable
|
|
341
|
+
json.dumps(data)
|
|
342
|
+
|
|
343
|
+
has_player = any(n["name"] == "Player" for n in data["nodes"])
|
|
344
|
+
has_spawn = any(f["name"] == "spawn" for f in data["functions"])
|
|
345
|
+
has_texture = any(a["type"] == "texture" for a in data["assets"])
|
|
346
|
+
has_scene = any(s.get("scene_name") == "level1" for s in data["scene_files"])
|
|
347
|
+
|
|
348
|
+
checks = [has_player, has_spawn, has_texture, has_scene]
|
|
349
|
+
passed = all(checks)
|
|
350
|
+
detail = (f"nodes={len(data['nodes'])} fns={len(data['functions'])} "
|
|
351
|
+
f"assets={len(data['assets'])} scene_files={len(data['scene_files'])}")
|
|
352
|
+
return GateResult(7, "Project introspection", passed, detail,
|
|
353
|
+
(time.perf_counter() - t0) * 1000)
|
|
354
|
+
finally:
|
|
355
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
356
|
+
except Exception as e:
|
|
357
|
+
return GateResult(7, "Project introspection", False, str(e),
|
|
358
|
+
(time.perf_counter() - t0) * 1000)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
362
|
+
# Main gate runner
|
|
363
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
def run_all_gates(base_dir: str = ".") -> Tuple[List[GateResult], bool]:
|
|
366
|
+
"""Run all 7 gates. Returns (results, all_passed)."""
|
|
367
|
+
print("\n╔══════════════════════════════════════════════════════════╗")
|
|
368
|
+
print("║ InScript v3.0.0 Readiness Gate (inscript check-v3) ║")
|
|
369
|
+
print("╚══════════════════════════════════════════════════════════╝\n")
|
|
370
|
+
|
|
371
|
+
runners = [
|
|
372
|
+
lambda: gate_performance(),
|
|
373
|
+
lambda: gate_memory(),
|
|
374
|
+
lambda: gate_stability(base_dir),
|
|
375
|
+
lambda: gate_error_json(),
|
|
376
|
+
lambda: gate_bridge(),
|
|
377
|
+
lambda: gate_plugin_api(),
|
|
378
|
+
lambda: gate_introspection(base_dir),
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
results = []
|
|
382
|
+
for runner in runners:
|
|
383
|
+
r = runner()
|
|
384
|
+
print(repr(r))
|
|
385
|
+
results.append(r)
|
|
386
|
+
|
|
387
|
+
passed_count = sum(1 for r in results if r.passed)
|
|
388
|
+
all_passed = passed_count == GATE_COUNT
|
|
389
|
+
|
|
390
|
+
print(f"\n{'─'*60}")
|
|
391
|
+
print(f"Gates passed: {passed_count}/{GATE_COUNT}")
|
|
392
|
+
|
|
393
|
+
if all_passed:
|
|
394
|
+
print("""
|
|
395
|
+
╔══════════════════════════════════════════════════════════╗
|
|
396
|
+
║ 🟢 ALL GATES PASS — v3.0.0 InScript Studio is GO! ║
|
|
397
|
+
║ ║
|
|
398
|
+
║ Scene system, asset pipeline, hot reload, physics, ║
|
|
399
|
+
║ multiplayer, export pipeline, and Studio bridge are ║
|
|
400
|
+
║ all in place. Begin v3.0.0 development. ║
|
|
401
|
+
╚══════════════════════════════════════════════════════════╝""")
|
|
402
|
+
else:
|
|
403
|
+
failed = [r for r in results if not r.passed]
|
|
404
|
+
print(f"\n🔴 {len(failed)} gate(s) failed — NOT ready for v3.0.0:")
|
|
405
|
+
for r in failed:
|
|
406
|
+
print(f" G{r.gate_id}: {r.name} — {r.detail}")
|
|
407
|
+
|
|
408
|
+
return results, all_passed
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|