AssayFluxScientificSystems 0.1.0__py3-none-any.whl

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.
assayflux/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """AssayFlux Scientific Systems: language runtime and developer tools."""
2
+ __title__ = "AssayFlux Scientific Systems"
3
+ __version__ = "0.1.0"
4
+ __author__ = "Md Safarujjaman"
5
+ __email__ = "assayflux@gmail.com"
6
+ __url__ = "https://www.assayflux.com"
assayflux/__main__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .cli import main
2
+ raise SystemExit(main())
assayflux/cli.py ADDED
@@ -0,0 +1,268 @@
1
+ """Command line interface for validation, interpretation, health and local reports."""
2
+ from __future__ import annotations
3
+ import argparse, json, sys, traceback
4
+ from pathlib import Path
5
+ from assayflux import __version__
6
+ from assayflux.diagnostics.model import AssayFluxError, SourceLocation, Diagnostic, Severity
7
+ from assayflux.diagnostics.reporter import write_report, read_reports
8
+ from assayflux.security.checkpoints import SecurityPolicy, read_source
9
+ from assayflux.language.parser import parse
10
+ from assayflux.language.semantic import check
11
+ from assayflux.runtime.interpreter import Interpreter
12
+ from assayflux.runtime.native_build import build_cpp
13
+ from assayflux.developer.doctor import run_doctor
14
+
15
+ def _load(path: Path, policy: SecurityPolicy):
16
+ source = read_source(path, policy)
17
+ program = parse(source, str(path.resolve()))
18
+ check(program)
19
+ return source, program
20
+
21
+ def _diagnostic(exc: AssayFluxError, report: bool = True):
22
+ d = exc.diagnostic()
23
+ loc = d.location
24
+ print(f"{d.severity.value.upper()} {d.code}: {d.message}", file=sys.stderr)
25
+ print(f" --> {loc.file}:{loc.line}:{loc.column}", file=sys.stderr)
26
+ if d.details: print(f" details: {d.details}", file=sys.stderr)
27
+ if d.suggestion: print(f" help: {d.suggestion}", file=sys.stderr)
28
+ if report:
29
+ try: print(f" report: {write_report(d)}", file=sys.stderr)
30
+ except OSError: print(" warning: could not persist local diagnostic report", file=sys.stderr)
31
+
32
+ def _unexpected(exc: Exception, source: str = "<cli>") -> int:
33
+ # Save frame metadata only; do not copy source lines, environment variables, or locals.
34
+ frames = traceback.extract_tb(exc.__traceback__)
35
+ safe_stack = tuple(f"{Path(frame.filename).name}:{frame.lineno} in {frame.name}" for frame in frames[-12:])
36
+ d = Diagnostic("AF-INTERNAL-UNEXPECTED", "internal", Severity.ERROR,
37
+ "Unexpected internal failure; please inspect the local report", SourceLocation(Path(source).name),
38
+ cause_type=type(exc).__name__, stack=safe_stack)
39
+ try: print(f"ERROR {d.code}: {d.message}\n report: {write_report(d)}", file=sys.stderr)
40
+ except OSError: print(f"ERROR {d.code}: {d.message}", file=sys.stderr)
41
+ return 2
42
+
43
+ def cmd_check(args):
44
+ policy = SecurityPolicy(max_source_bytes=args.max_source_bytes)
45
+ try: _load(Path(args.file), policy)
46
+ except AssayFluxError as exc: _diagnostic(exc); return 1
47
+ print(f"OK: {args.file} passed source, syntax, AST, and semantic checks")
48
+ return 0
49
+
50
+ def cmd_run(args):
51
+ modules = tuple(x.strip() for x in args.python_modules.split(",") if x.strip()) if args.allow_python else ()
52
+ allowed = {"math", "statistics", "json", "numpy", "pandas", "scipy.stats"}
53
+ if any(m not in allowed for m in modules):
54
+ print("ERROR: Python bridge currently supports only: math,statistics,json,numpy,pandas,scipy.stats", file=sys.stderr); return 2
55
+ grants = tuple(["python.call"] + [f"python.module:{m}" for m in modules]) if modules else ()
56
+ policy = SecurityPolicy(max_source_bytes=args.max_source_bytes, max_steps=args.max_steps,
57
+ max_loop_iterations=args.max_loop_iterations, max_output_chars=args.max_output_chars,
58
+ capabilities=grants, allowed_python_modules=modules or ("math", "statistics", "json"))
59
+ try:
60
+ _, program = _load(Path(args.file), policy)
61
+ result = Interpreter(policy).run(program)
62
+ for line in result.output: print(line)
63
+ if args.stats: print(f"[AssayFlux] steps={result.steps} variables={len(result.variables)}", file=sys.stderr)
64
+ return 0
65
+ except AssayFluxError as exc: _diagnostic(exc); return 1
66
+ except Exception as exc: return _unexpected(exc, str(args.file))
67
+
68
+ def cmd_build(args):
69
+ try:
70
+ result = build_cpp(args.source, args.output, workspace=args.workspace, compiler=args.compiler, timeout=args.timeout, capabilities={"native.build"})
71
+ print(f"Built {result.output} with {result.compiler} in {result.duration_seconds:.2f}s")
72
+ if result.warnings.strip(): print(result.warnings.rstrip(), file=sys.stderr)
73
+ return 0
74
+ except AssayFluxError as exc:
75
+ _diagnostic(exc); return 1
76
+ except Exception as exc: return _unexpected(exc, str(args.source))
77
+
78
+ def cmd_doctor(args):
79
+ checks = run_doctor(); failed = False
80
+ print(f"AssayFlux Doctor {__version__}\n")
81
+ for c in checks:
82
+ print(f"[{c.status}] {c.name}: {c.detail}")
83
+ if c.required and c.status != "PASS": failed = True
84
+ print("\nStatus:", "FAILED REQUIRED CHECKS" if failed else "READY (optional tools may be missing)")
85
+ return 1 if failed else 0
86
+
87
+ def cmd_reports(args):
88
+ rows = read_reports()
89
+ if args.report_action == "list":
90
+ if not rows: print("No local diagnostic reports found.")
91
+ for i, row in enumerate(rows, 1): print(f"{i}. {row.get('timestamp','?')} {row.get('code','?')} {row.get('message','')}")
92
+ return 0
93
+ if args.report_action == "show":
94
+ if args.index < 1 or args.index > len(rows): print("Report index out of range", file=sys.stderr); return 2
95
+ print(json.dumps(rows[args.index-1], indent=2, ensure_ascii=False)); return 0
96
+ if args.report_action == "export":
97
+ dest = Path(args.path)
98
+ try: dest.write_text(json.dumps(rows, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
99
+ except OSError as exc: print(f"Could not export reports: {type(exc).__name__}", file=sys.stderr); return 1
100
+ print(f"Exported {len(rows)} report(s) to {dest}"); return 0
101
+ return 2
102
+
103
+ def cmd_setup(args):
104
+ import subprocess
105
+ extras = []
106
+ if args.science: extras.append("science")
107
+ if args.dev: extras.append("dev")
108
+ if not extras:
109
+ print("Core Python dependencies are installed automatically by pip. Choose --science to install NumPy/SciPy/pandas/Matplotlib, or --dev for test/build tools.")
110
+ print("External tools such as JDK, Android SDK, Gradle bootstrap, and g++ are platform-specific; run 'assayflux doctor' to inspect them.")
111
+ return 0
112
+ spec = "AssayFluxScientificSystems[" + ",".join(extras) + "]==" + __version__
113
+ try:
114
+ proc = subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", spec], check=False, timeout=args.timeout)
115
+ except subprocess.TimeoutExpired:
116
+ print("Optional dependency installation timed out", file=sys.stderr); return 124
117
+ except OSError as exc:
118
+ print(f"Could not start pip: {type(exc).__name__}", file=sys.stderr); return 1
119
+ if proc.returncode == 0: print("Optional Python extras installed. Run 'assayflux doctor' to recheck the environment.")
120
+ else: print("Optional extras installation failed; check network access, package publication, and pip output.", file=sys.stderr)
121
+ return proc.returncode
122
+
123
+ def cmd_syntax(args):
124
+ catalog = {
125
+ "variables": ["let name = value — declare a variable once", "name = value — assign an existing variable", "Strings use single or double quotes; numbers are integers or decimals."],
126
+ "types": ["Built-in values: number, string, boolean (true/false), null.", "Collections and user-defined functions are not implemented in the current core grammar."],
127
+ "operators": ["Arithmetic: + - * / %", "Comparisons: == != < > <= >=", "Unary: -value, not boolean", "Conditions must evaluate to true or false."],
128
+ "control": ['app "Application name" — optional declaration', "print(expression) — write one output line", "if condition { ... } else { ... }", "while condition { ... }", "Statements can be separated by a newline or semicolon."],
129
+ "python": ['pycall("math", "sqrt", 81) — call an approved Python function', "Run with: assayflux run file.assayflux --allow-python --python-modules math", "Supported bridge modules: math, statistics, json, and optional numpy, pandas, and scipy.stats. Calls are explicitly allowlisted; this is not a sandbox."],
130
+ "security": ["Default execution denies Python bridge capabilities.", "Source, step, loop, output, numeric and AST limits are applied.", "Never treat in-process language controls as an OS sandbox; don't execute untrusted native code."],
131
+ }
132
+ keys = catalog if args.topic == "all" else {args.topic: catalog[args.topic]}
133
+ print("AssayFlux Language Syntax Reference 0.1.0")
134
+ for topic, entries in keys.items():
135
+ print(f"\n[{topic.upper()}]")
136
+ for entry in entries: print(f" • {entry}")
137
+ if args.topic == "all":
138
+ print("\n[EXAMPLE]")
139
+ print(' app "Scientific demo"\n let value = 6 * 7\n print(value)\n print(pycall("math", "sqrt", 81))')
140
+ return 0
141
+
142
+ def cmd_project_create(args):
143
+ dest = Path(args.path).expanduser().resolve()
144
+ try:
145
+ dest.mkdir(parents=True, exist_ok=True)
146
+ import json as _json
147
+ safe_app_name = _json.dumps(args.name, ensure_ascii=False)
148
+ (dest / "main.assayflux").write_text(f'app {safe_app_name}\nlet answer = 6 * 7\nprint(answer)\n', encoding="utf-8")
149
+ (dest / "README.md").write_text(f"# {args.name}\n\nCreated with AssayFlux Scientific Systems 0.1.0.\n\nRun: `assayflux run main.assayflux`\n", encoding="utf-8")
150
+ except OSError as exc:
151
+ print(f"Could not create project: {type(exc).__name__}", file=sys.stderr); return 1
152
+ print(f"Created starter project at {dest}"); return 0
153
+
154
+ def cmd_os(args):
155
+ if args.action == "roadmap":
156
+ print("AssayFluxOS roadmap: host CLI/workspace prototype → Linux-based development environment → customized distribution → research toward an independent kernel.")
157
+ else:
158
+ print("AssayFluxOS tools are a host-side development prototype bundled with AssayFlux Scientific Systems.")
159
+ print("This command does not boot a separate OS or provide a kernel/distribution.")
160
+ return 0
161
+
162
+ def cmd_android_wrapper(args):
163
+ import shutil, subprocess
164
+ project = Path(args.project).expanduser().resolve()
165
+ if not project.is_dir(): print("Android project directory not found", file=sys.stderr); return 2
166
+ gradle = shutil.which("gradle")
167
+ if not gradle:
168
+ print("Gradle is required once to generate the wrapper. Install Gradle, then rerun this command.", file=sys.stderr)
169
+ print("After generation, gradlew downloads the configured Gradle distribution on first use.", file=sys.stderr)
170
+ return 2
171
+ try:
172
+ proc = subprocess.run([gradle, "wrapper", f"--gradle-version={args.gradle_version}"], cwd=project,
173
+ capture_output=True, text=True, timeout=180, check=False)
174
+ except (OSError, subprocess.TimeoutExpired) as exc:
175
+ print(f"Gradle wrapper generation failed: {type(exc).__name__}", file=sys.stderr); return 1
176
+ if proc.returncode:
177
+ print((proc.stderr or proc.stdout)[-4000:], file=sys.stderr); return proc.returncode
178
+ print(f"Gradle wrapper generated in {project}. Review and commit gradlew, gradlew.bat and gradle/wrapper/.")
179
+ return 0
180
+
181
+ def cmd_android_build(args):
182
+ import os, subprocess
183
+ project = Path(args.project).expanduser().resolve()
184
+ wrapper = project / ("gradlew.bat" if os.name == "nt" else "gradlew")
185
+ if not project.is_dir(): print("Android project directory not found", file=sys.stderr); return 2
186
+ if not wrapper.is_file():
187
+ print("Gradle wrapper is missing. Generate it first: assayflux android wrapper PROJECT --gradle-version 8.9", file=sys.stderr)
188
+ return 2
189
+ if not (os.environ.get("ANDROID_HOME") or os.environ.get("ANDROID_SDK_ROOT")):
190
+ print("Warning: ANDROID_HOME/ANDROID_SDK_ROOT is not set; Gradle may still find an SDK from local configuration.", file=sys.stderr)
191
+ if os.name != "nt":
192
+ try: wrapper.chmod(wrapper.stat().st_mode | 0o111)
193
+ except OSError: pass
194
+ task = ("bundle" if args.bundle else "assemble") + args.variant.title()
195
+ command = [str(wrapper), task]
196
+ try:
197
+ proc = subprocess.run(command, cwd=project, capture_output=True, text=True, timeout=args.timeout, check=False)
198
+ except subprocess.TimeoutExpired:
199
+ print(f"Android build timed out after {args.timeout} seconds", file=sys.stderr); return 124
200
+ except OSError as exc:
201
+ print(f"Could not start Gradle wrapper: {type(exc).__name__}", file=sys.stderr); return 1
202
+ if proc.stdout: print(proc.stdout[-12000:])
203
+ if proc.stderr: print(proc.stderr[-8000:], file=sys.stderr)
204
+ if proc.returncode:
205
+ print(f"Android build failed (exit {proc.returncode}). Check JDK, Android SDK/licenses, plugin compatibility, and project sources.", file=sys.stderr)
206
+ return proc.returncode
207
+ print(f"Android Gradle task completed: {task}. Check the project's build/outputs directory for APK/AAB artifacts.")
208
+ return 0
209
+
210
+ def build_parser():
211
+ p = argparse.ArgumentParser(prog="assayflux", description="AssayFlux language and secure development tools")
212
+ p.add_argument("--version", action="version", version=f"AssayFlux {__version__}")
213
+ sub = p.add_subparsers(dest="command", required=True)
214
+ check_p = sub.add_parser("check", help="validate source without executing it")
215
+ check_p.add_argument("file"); check_p.add_argument("--max-source-bytes", type=int, default=1_000_000); check_p.set_defaults(func=cmd_check)
216
+ run_p = sub.add_parser("run", help="validate and interpret a source file")
217
+ run_p.add_argument("file"); run_p.add_argument("--max-source-bytes", type=int, default=1_000_000)
218
+ run_p.add_argument("--max-steps", type=int, default=100_000); run_p.add_argument("--max-loop-iterations", type=int, default=10_000)
219
+ run_p.add_argument("--max-output-chars", type=int, default=1_000_000); run_p.add_argument("--stats", action="store_true")
220
+ run_p.add_argument("--allow-python", action="store_true", help="grant explicit access to approved Python bridge modules")
221
+ run_p.add_argument("--python-modules", default="math,statistics,json", help="comma-separated allowlist used with --allow-python")
222
+ run_p.set_defaults(func=cmd_run)
223
+ build = sub.add_parser("build", help="compile trusted generated C++ source; does not execute it")
224
+ build.add_argument("source"); build.add_argument("--output", default="build/assayflux-native")
225
+ build.add_argument("--workspace", default="."); build.add_argument("--compiler", choices=("g++", "clang++"), default="g++")
226
+ build.add_argument("--timeout", type=float, default=30.0); build.set_defaults(func=cmd_build)
227
+ doc = sub.add_parser("doctor", help="check development environment health"); doc.set_defaults(func=cmd_doctor)
228
+ reports = sub.add_parser("report", help="inspect local diagnostic reports"); rs = reports.add_subparsers(dest="report_action", required=True)
229
+ rs.add_parser("list")
230
+ show = rs.add_parser("show"); show.add_argument("index", type=int)
231
+ export = rs.add_parser("export"); export.add_argument("path")
232
+ reports.set_defaults(func=cmd_reports)
233
+ setup = sub.add_parser("setup", help="install optional Python helper dependencies")
234
+ setup.add_argument("--science", action="store_true", help="install NumPy, SciPy, pandas, and Matplotlib")
235
+ setup.add_argument("--dev", action="store_true", help="install pytest, build, ruff, and twine")
236
+ setup.add_argument("--timeout", type=float, default=900)
237
+ setup.set_defaults(func=cmd_setup)
238
+ syntax = sub.add_parser("syntax", help="show built-in language syntax, rules, and examples")
239
+ syntax.add_argument("topic", nargs="?", default="all", choices=("all", "variables", "types", "operators", "control", "python", "security"))
240
+ syntax.set_defaults(func=cmd_syntax)
241
+ project = sub.add_parser("project", help="project builder utilities")
242
+ ps = project.add_subparsers(dest="project_action", required=True)
243
+ create = ps.add_parser("create", help="create a starter AssayFlux project")
244
+ create.add_argument("path", nargs="?", default="."); create.add_argument("--name", default="MyAssayFluxProject")
245
+ create.set_defaults(func=cmd_project_create)
246
+ os_cmd = sub.add_parser("os", help="AssayFluxOS development utilities (not a standalone OS)")
247
+ os_cmd.add_argument("action", nargs="?", default="info", choices=("info", "roadmap")); os_cmd.set_defaults(func=cmd_os)
248
+ android = sub.add_parser("android", help="Android APK/AAB build helpers using Gradle")
249
+ android_sub = android.add_subparsers(dest="android_action", required=True)
250
+ wrapper = android_sub.add_parser("wrapper", help="generate Gradle wrapper files")
251
+ wrapper.add_argument("project", nargs="?", default="."); wrapper.add_argument("--gradle-version", default="8.9")
252
+ wrapper.set_defaults(func=cmd_android_wrapper)
253
+ android_build = android_sub.add_parser("build", help="run assembleDebug or bundleRelease with the project wrapper")
254
+ android_build.add_argument("project", nargs="?", default=".")
255
+ android_build.add_argument("--variant", choices=("debug", "release"), default="debug")
256
+ android_build.add_argument("--bundle", action="store_true", help="build an Android App Bundle (AAB) instead of APK")
257
+ android_build.add_argument("--timeout", type=float, default=1800)
258
+ android_build.set_defaults(func=cmd_android_build)
259
+ return p
260
+
261
+ def main(argv=None):
262
+ args = build_parser().parse_args(argv)
263
+ try: return args.func(args)
264
+ except KeyboardInterrupt: print("Interrupted", file=sys.stderr); return 130
265
+ except AssayFluxError as exc: _diagnostic(exc); return 1
266
+ except Exception as exc: return _unexpected(exc, getattr(args, "file", getattr(args, "source", "<cli>")))
267
+
268
+ if __name__ == "__main__": raise SystemExit(main())
@@ -0,0 +1 @@
1
+ from .doctor import run_doctor
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+ import importlib.util, platform, shutil, sys
3
+ from dataclasses import dataclass
4
+
5
+ @dataclass(frozen=True)
6
+ class Check:
7
+ name: str
8
+ status: str
9
+ detail: str
10
+ required: bool = False
11
+
12
+ def run_doctor() -> list[Check]:
13
+ checks = [Check("Python", "PASS" if sys.version_info >= (3,11) else "FAIL", platform.python_version(), True),
14
+ Check("Lark parser", "PASS" if importlib.util.find_spec("lark") else "FAIL", "lark package", True),
15
+ Check("SQLite", "PASS" if importlib.util.find_spec("sqlite3") else "FAIL", "Python sqlite3 module", True)]
16
+ for tool in ("g++", "clang++", "node", "git", "java", "gradle", "adb"):
17
+ path = shutil.which(tool); checks.append(Check(tool, "AVAILABLE" if path else "OPTIONAL-MISSING", path or "not found"))
18
+ checks.append(Check("Android SDK", "AVAILABLE" if (__import__("os").environ.get("ANDROID_HOME") or __import__("os").environ.get("ANDROID_SDK_ROOT")) else "OPTIONAL-MISSING", "ANDROID_HOME/ANDROID_SDK_ROOT"))
19
+ return checks
@@ -0,0 +1,2 @@
1
+ from .model import *
2
+ from .reporter import write_report, read_reports
@@ -0,0 +1,61 @@
1
+ """Stable diagnostic model shared by compiler stages and runtime."""
2
+ from __future__ import annotations
3
+ from dataclasses import asdict, dataclass, field
4
+ from datetime import datetime, timezone
5
+ from enum import StrEnum
6
+ from typing import Any
7
+
8
+ class Severity(StrEnum):
9
+ ERROR = "error"
10
+ WARNING = "warning"
11
+ INFO = "info"
12
+ HINT = "hint"
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class SourceLocation:
16
+ file: str = "<memory>"
17
+ line: int = 1
18
+ column: int = 1
19
+ end_line: int | None = None
20
+ end_column: int | None = None
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class Diagnostic:
24
+ code: str
25
+ category: str
26
+ severity: Severity
27
+ message: str
28
+ location: SourceLocation = field(default_factory=SourceLocation)
29
+ details: str | None = None
30
+ suggestion: str | None = None
31
+ cause_type: str | None = None
32
+ stack: tuple[str, ...] = ()
33
+ timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
34
+ def to_dict(self) -> dict[str, Any]:
35
+ return asdict(self)
36
+
37
+ class AssayFluxError(Exception):
38
+ code = "AF-INTERNAL-001"
39
+ category = "internal"
40
+ def __init__(self, message: str, *, location: SourceLocation | None = None,
41
+ details: str | None = None, suggestion: str | None = None):
42
+ super().__init__(message)
43
+ self.message = message
44
+ self.location = location or SourceLocation()
45
+ self.details = details
46
+ self.suggestion = suggestion
47
+ def diagnostic(self) -> Diagnostic:
48
+ return Diagnostic(self.code, self.category, Severity.ERROR, self.message,
49
+ self.location, self.details, self.suggestion, type(self).__name__)
50
+
51
+ class SourceError(AssayFluxError): code = "AF-SOURCE-001"; category = "source"
52
+ class LexError(AssayFluxError): code = "AF-LEX-001"; category = "lexical"
53
+ class ParseError(AssayFluxError): code = "AF-PARSE-001"; category = "syntax"
54
+ class AstError(AssayFluxError): code = "AF-AST-001"; category = "ast"
55
+ class TypeCheckError(AssayFluxError): code = "AF-TYPE-001"; category = "type"
56
+ class SecurityError(AssayFluxError): code = "AF-SEC-001"; category = "security"
57
+ class RuntimeErrorAF(AssayFluxError): code = "AF-RUNTIME-001"; category = "runtime"
58
+ class ImportErrorAF(AssayFluxError): code = "AF-IMPORT-001"; category = "import"
59
+ class PythonBridgeError(AssayFluxError): code = "AF-PYTHON-001"; category = "python-interop"
60
+ class DatabaseErrorAF(AssayFluxError): code = "AF-SQL-001"; category = "database"
61
+ class BuildError(AssayFluxError): code = "AF-BUILD-001"; category = "build"
@@ -0,0 +1,60 @@
1
+ """Local-first structured bug/diagnostic reporting; never uploads automatically."""
2
+ from __future__ import annotations
3
+ import json, os, tempfile
4
+ from pathlib import Path
5
+ from typing import Iterable
6
+ from .model import Diagnostic
7
+
8
+ DEFAULT_REPORT_DIR = Path(os.environ.get("ASSAYFLUX_REPORT_DIR", Path.home() / ".assayflux" / "reports"))
9
+ MAX_REPORT_BYTES = 10 * 1024 * 1024
10
+ MAX_REPORTS = 20_000
11
+
12
+ def _private_dir(path: Path) -> None:
13
+ path.mkdir(parents=True, exist_ok=True, mode=0o700)
14
+ try: path.chmod(0o700)
15
+ except OSError: pass
16
+
17
+ def write_report(diagnostic: Diagnostic, report_dir: Path | None = None) -> Path:
18
+ """Atomically append a minimized JSONL report. Source contents are never captured."""
19
+ root = report_dir or DEFAULT_REPORT_DIR
20
+ _private_dir(root)
21
+ target = root / "diagnostics.jsonl"
22
+ rotated = root / "diagnostics.previous.jsonl"
23
+ try:
24
+ if target.stat().st_size >= MAX_REPORT_BYTES:
25
+ try: rotated.unlink()
26
+ except FileNotFoundError: pass
27
+ os.replace(target, rotated)
28
+ try: rotated.chmod(0o600)
29
+ except OSError: pass
30
+ except FileNotFoundError: pass
31
+ record = json.dumps(diagnostic.to_dict(), ensure_ascii=False, sort_keys=True)
32
+ # Use a single O_APPEND write to reduce interleaving between cooperating processes.
33
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
34
+ try:
35
+ os.write(fd, (record + "\n").encode("utf-8"))
36
+ finally: os.close(fd)
37
+ try: target.chmod(0o600)
38
+ except OSError: pass
39
+ return target
40
+
41
+ def read_reports(report_dir: Path | None = None) -> list[dict]:
42
+ root = report_dir or DEFAULT_REPORT_DIR
43
+ rows: list[dict] = []
44
+ for path in (root / "diagnostics.previous.jsonl", root / "diagnostics.jsonl"):
45
+ if not path.exists(): continue
46
+ try:
47
+ size = path.stat().st_size
48
+ with path.open("rb") as stream:
49
+ if size > MAX_REPORT_BYTES: stream.seek(size - MAX_REPORT_BYTES)
50
+ raw = stream.read(MAX_REPORT_BYTES + 1)
51
+ text = raw.decode("utf-8", errors="replace")
52
+ if size > MAX_REPORT_BYTES: text = text.split("\n", 1)[-1]
53
+ except OSError:
54
+ continue
55
+ for line_no, line in enumerate(text.splitlines(), 1):
56
+ if len(rows) >= MAX_REPORTS: return rows
57
+ try: rows.append(json.loads(line))
58
+ except json.JSONDecodeError:
59
+ rows.append({"code":"AF-REPORT-CORRUPT", "line":line_no, "message":"Malformed report entry"})
60
+ return rows
@@ -0,0 +1,36 @@
1
+ """Small immutable AST. All source locations are preserved for diagnostics."""
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+ from assayflux.diagnostics.model import SourceLocation
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class Node:
9
+ loc: SourceLocation
10
+ @dataclass(frozen=True, slots=True)
11
+ class Program(Node): statements: tuple[Node, ...]
12
+ @dataclass(frozen=True, slots=True)
13
+ class AppDecl(Node): name: str
14
+ @dataclass(frozen=True, slots=True)
15
+ class LetDecl(Node): name: str; value: Node
16
+ @dataclass(frozen=True, slots=True)
17
+ class Assign(Node): name: str; value: Node
18
+ @dataclass(frozen=True, slots=True)
19
+ class Print(Node): value: Node | None
20
+ @dataclass(frozen=True, slots=True)
21
+ class If(Node): condition: Node; then_body: tuple[Node, ...]; else_body: tuple[Node, ...]
22
+ @dataclass(frozen=True, slots=True)
23
+ class While(Node): condition: Node; body: tuple[Node, ...]
24
+ @dataclass(frozen=True, slots=True)
25
+ class ExprStmt(Node): value: Node
26
+ @dataclass(frozen=True, slots=True)
27
+ class Literal(Node): value: Any
28
+ @dataclass(frozen=True, slots=True)
29
+ class Variable(Node): name: str
30
+ @dataclass(frozen=True, slots=True)
31
+ class Unary(Node): op: str; value: Node
32
+ @dataclass(frozen=True, slots=True)
33
+ class Binary(Node): left: Node; op: str; right: Node
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class PythonCall(Node): module: str; function: str; args: tuple[Node, ...]
@@ -0,0 +1,30 @@
1
+ """Checkpoint validator for AST shape, supported node types, and nesting depth."""
2
+ from __future__ import annotations
3
+ from dataclasses import fields, is_dataclass
4
+ from assayflux.diagnostics.model import AstError
5
+ from . import ast_nodes as A
6
+
7
+ _ALLOWED = {A.Program, A.AppDecl, A.LetDecl, A.Assign, A.Print, A.If, A.While,
8
+ A.ExprStmt, A.Literal, A.Variable, A.Unary, A.Binary, A.PythonCall}
9
+ _MAX_DEPTH = 512
10
+
11
+ def validate_ast(root: A.Program) -> A.Program:
12
+ if type(root) is not A.Program: raise AstError("AST root must be a Program node")
13
+ seen = 0
14
+ def visit(node, depth):
15
+ nonlocal seen
16
+ seen += 1
17
+ if seen > 1_000_000: raise AstError("AST node count exceeds safety limit", location=node.loc)
18
+ if depth > _MAX_DEPTH: raise AstError("AST nesting depth exceeds safety limit", location=node.loc)
19
+ if type(node) not in _ALLOWED: raise AstError(f"Unsupported AST node type: {type(node).__name__}", location=getattr(node, "loc", None))
20
+ for f in fields(node):
21
+ value = getattr(node, f.name)
22
+ if isinstance(value, A.Node): visit(value, depth + 1)
23
+ elif isinstance(value, tuple):
24
+ for item in value:
25
+ if isinstance(item, A.Node): visit(item, depth + 1)
26
+ elif isinstance(item, tuple):
27
+ for nested in item:
28
+ if isinstance(nested, A.Node): visit(nested, depth + 1)
29
+ visit(root, 0)
30
+ return root
@@ -0,0 +1,43 @@
1
+ // AssayFlux Scientific Systems 0.1.0 active parser grammar. Deliberately small; see docs/SYNTAX_REFERENCE.md.
2
+ start: _NL* (statement _NL*)*
3
+ ?statement: app_decl | let_decl | assign_stmt | print_stmt | if_stmt | while_stmt | expr_stmt
4
+ app_decl: "app" STRING
5
+ let_decl: "let" NAME "=" expr
6
+ assign_stmt: NAME "=" expr
7
+ print_stmt: "print" "(" [expr] ")"
8
+ if_stmt: "if" expr block ["else" block]
9
+ while_stmt: "while" expr block
10
+ block: "{" _NL* (statement _NL*)* "}"
11
+ expr_stmt: expr
12
+ ?expr: comparison
13
+ ?comparison: sum (COMP_OP sum)*
14
+ ?sum: term (ADD_OP term)*
15
+ ?term: unary (MUL_OP unary)*
16
+ ?unary: NOT unary -> unary_expr
17
+ | UNARY_MINUS unary -> unary_expr
18
+ | atom
19
+ ?atom: pycall_expr
20
+ | NUMBER -> number
21
+ | STRING -> string
22
+ | TRUE -> true
23
+ | FALSE -> false
24
+ | NULL -> null
25
+ | NAME -> variable
26
+ | "(" expr ")"
27
+ pycall_expr: "pycall" "(" STRING "," STRING ("," expr)* ")"
28
+ ADD_OP: "+" | "-"
29
+ MUL_OP: "*" | "/" | "%"
30
+ COMP_OP: "==" | "!=" | "<=" | ">=" | "<" | ">"
31
+ UNARY_MINUS.2: "-"
32
+ NOT.2: "not"
33
+ TRUE.2: "true"
34
+ FALSE.2: "false"
35
+ NULL.2: "null"
36
+ NAME: /[A-Za-z_][A-Za-z0-9_]*/
37
+ NUMBER: /(?:0|[1-9][0-9]*)(?:\.[0-9]+)?/
38
+ STRING: /"([^"\\]|\\.)*"|'([^'\\]|\\.)*'/
39
+ COMMENT: /#[^\n]*/
40
+ _NL: /(?:\r?\n|;)+/
41
+ %import common.WS_INLINE
42
+ %ignore WS_INLINE
43
+ %ignore COMMENT
@@ -0,0 +1,95 @@
1
+ """Lark-backed parser and AST builder. No regex-based grammar parsing."""
2
+ from __future__ import annotations
3
+ import ast as py_ast
4
+ from pathlib import Path
5
+ from lark import Lark, Transformer, Token, UnexpectedInput
6
+ from lark.exceptions import VisitError
7
+ from assayflux.diagnostics.model import SourceLocation, ParseError, AstError
8
+ from . import ast_nodes as A
9
+ from .ast_validator import validate_ast
10
+
11
+ _GRAMMAR = Path(__file__).with_name("grammar.lark").read_text(encoding="utf-8")
12
+ _PARSER = Lark(_GRAMMAR, parser="lalr", lexer="contextual", propagate_positions=True,
13
+ maybe_placeholders=False, start="start")
14
+
15
+ def _loc(meta, filename: str) -> SourceLocation:
16
+ return SourceLocation(filename, getattr(meta, "line", 1) or 1,
17
+ getattr(meta, "column", 1) or 1,
18
+ getattr(meta, "end_line", None), getattr(meta, "end_column", None))
19
+
20
+ class _BuildAST(Transformer):
21
+ def __init__(self, filename: str): super().__init__(); self.filename = filename
22
+ def start(self, items):
23
+ loc = SourceLocation(self.filename, 1, 1)
24
+ return A.Program(loc, tuple(x for x in items if isinstance(x, A.Node)))
25
+ def app_decl(self, items):
26
+ token = items[0]; return A.AppDecl(_token_loc(token, self.filename), _decode_string(str(token)))
27
+ def let_decl(self, items):
28
+ name, value = items; return A.LetDecl(_token_loc(name, self.filename), str(name), value)
29
+ def assign_stmt(self, items):
30
+ name, value = items; return A.Assign(_token_loc(name, self.filename), str(name), value)
31
+ def print_stmt(self, items):
32
+ return A.Print(_node_loc(items[0], self.filename) if items else SourceLocation(self.filename), items[0] if items else None)
33
+ def if_stmt(self, items):
34
+ condition = items[0]; blocks = [x for x in items[1:] if isinstance(x, tuple)]
35
+ return A.If(_node_loc(condition, self.filename), condition, blocks[0] if blocks else (), blocks[1] if len(blocks)>1 else ())
36
+ def while_stmt(self, items):
37
+ condition = items[0]; body = next((x for x in items[1:] if isinstance(x, tuple)), ())
38
+ return A.While(_node_loc(condition, self.filename), condition, body)
39
+ def block(self, items): return tuple(x for x in items if isinstance(x, A.Node))
40
+ def expr_stmt(self, items): return A.ExprStmt(_node_loc(items[0], self.filename), items[0])
41
+ def number(self, items):
42
+ s = str(items[0])
43
+ if len(s) > 1234: raise ParseError("Numeric literal exceeds the configured safety limit", location=_token_loc(items[0], self.filename))
44
+ return A.Literal(_token_loc(items[0], self.filename), float(s) if "." in s else int(s))
45
+ def string(self, items): return A.Literal(_token_loc(items[0], self.filename), _decode_string(str(items[0])))
46
+ def true(self, items): return A.Literal(_token_loc(items[0], self.filename), True)
47
+ def false(self, items): return A.Literal(_token_loc(items[0], self.filename), False)
48
+ def null(self, items): return A.Literal(_token_loc(items[0], self.filename), None)
49
+ def variable(self, items): return A.Variable(_token_loc(items[0], self.filename), str(items[0]))
50
+ def pycall_expr(self, items):
51
+ module, function = _decode_string(str(items[0])), _decode_string(str(items[1]))
52
+ if not all(part.isidentifier() and not part.startswith("_") for part in module.split(".")) or not function.isidentifier() or function.startswith("_"):
53
+ raise ParseError("pycall requires a module and public function name", location=_token_loc(items[0], self.filename))
54
+ return A.PythonCall(_token_loc(items[0], self.filename), module, function, tuple(items[2:]))
55
+ def unary_expr(self, items): return A.Unary(_token_loc(items[0], self.filename), "-" if str(items[0]) == "-" else "not", items[1])
56
+ def comparison(self, items): return _fold(items, self.filename)
57
+ def sum(self, items): return _fold(items, self.filename)
58
+ def term(self, items): return _fold(items, self.filename)
59
+
60
+ def _token_loc(token: Token, filename: str) -> SourceLocation:
61
+ return SourceLocation(filename, getattr(token, "line", 1) or 1, getattr(token, "column", 1) or 1,
62
+ getattr(token, "end_line", None), getattr(token, "end_column", None))
63
+ def _node_loc(node, filename: str) -> SourceLocation:
64
+ return getattr(node, "loc", SourceLocation(filename))
65
+ def _fold(items, filename):
66
+ if not items: raise AstError("Empty expression while building AST")
67
+ node = items[0]
68
+ for i in range(1, len(items), 2):
69
+ op, rhs = str(items[i]), items[i+1]
70
+ node = A.Binary(_node_loc(node, filename), node, op, rhs)
71
+ return node
72
+ def _decode_string(s: str) -> str:
73
+ try: return py_ast.literal_eval(s)
74
+ except (ValueError, SyntaxError) as exc: raise ParseError("Invalid string literal", details=str(exc)) from exc
75
+
76
+ def parse(source: str, filename: str = "<memory>") -> A.Program:
77
+ if not isinstance(source, str): raise ParseError("Source must be text", location=SourceLocation(filename))
78
+ try:
79
+ tree = _PARSER.parse(source)
80
+ result = _BuildAST(filename).transform(tree)
81
+ except RecursionError as exc:
82
+ raise ParseError("Expression nesting is too deep", location=SourceLocation(filename),
83
+ suggestion="Simplify deeply nested expressions.") from exc
84
+ except UnexpectedInput as exc:
85
+ expected = ", ".join(sorted(getattr(exc, "expected", ()) or ()))
86
+ raise ParseError("Invalid syntax", location=SourceLocation(filename, exc.line or 1, exc.column or 1),
87
+ details=f"Unexpected input. Expected: {expected or 'a valid expression or statement'}",
88
+ suggestion="Check the highlighted line for missing quotes, operators, or braces.") from exc
89
+ except VisitError as exc:
90
+ orig = exc.orig_exc
91
+ if isinstance(orig, (ParseError, AstError)): raise orig
92
+ raise AstError("Could not construct a valid syntax tree", location=SourceLocation(filename), details=type(orig).__name__) from orig
93
+ if not isinstance(result, A.Program):
94
+ raise AstError("Parser did not produce a Program node", location=SourceLocation(filename))
95
+ return validate_ast(result)