Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1082d9d61e | ||
|
|
e3a94fd16b | ||
|
|
532724baf7 |
+39
-9
@@ -6,6 +6,31 @@ Sandwich Hime follows semantic versioning after final v1. Compiler and nested
|
||||
runtime releases are versioned independently and listed together when they form
|
||||
one coordinated release.
|
||||
|
||||
## v1.0.0-beta.2 — 2026-08-12
|
||||
|
||||
Compiler-only release; the unchanged Apache runtime remains
|
||||
`sando/v1.0.0-beta.1` with ABI `sando.v1`.
|
||||
|
||||
### Added
|
||||
|
||||
- Standard-library-only `himesan lsp --stdio` with one workspace per process,
|
||||
full-document overlays, 200 ms edit debounce, cancellation, and bounded
|
||||
indexing through the compiler's existing filesystem boundaries.
|
||||
- Live diagnostics, trust warnings, duplicate/cycle reporting, UTF-16 LSP
|
||||
positions, tag/context/component hover, document symbols, typed component
|
||||
completion, and component go-to-definition.
|
||||
- Additive `features: ["lsp-stdio"]` in `himesan version --json`.
|
||||
- Protocol framing and malformed-input fuzzing; overlay, Unicode, CRLF, NUL,
|
||||
deletion, nested-module, symlink, completion, definition, cancellation,
|
||||
shutdown, no-write, and resource-limit tests.
|
||||
|
||||
### Boundaries
|
||||
|
||||
The server does not generate, run Go or project code, fetch dependencies,
|
||||
access the network, start the dev supervisor, format, rename, add imports, or
|
||||
delegate general Go completion to `gopls`. Generated-file freshness remains an
|
||||
explicit `himesan check --json` workflow.
|
||||
|
||||
## v1.0.0-beta.1 — 2026-08-12
|
||||
|
||||
This is the first installable public beta: `sando/v1.0.0-beta.1` for the
|
||||
@@ -27,24 +52,29 @@ not a production-stability promise.
|
||||
- Public beta support policy for evaluation and classroom use, including a
|
||||
provisional macOS lane and a community compatibility-reporting path.
|
||||
|
||||
### Pre-beta verification baseline
|
||||
### Release verification
|
||||
|
||||
Maintainer-run Linux and native Windows matrices passed on public commit
|
||||
`113c95c21e57227b4675c9fda015ada59cc9e9a6` (tree
|
||||
`a2aeb4dac22853cb3894e3e487b94bbeff5051e5`) with Go 1.25.12 and Go
|
||||
1.26.5. The tested golden output had the same SHA-256 on each tested host:
|
||||
The exact public Beta 1 commit
|
||||
`b7a84054d755e42285e50298e41e47f06a8325a5` (tree
|
||||
`be9e118e38dfebed19f60403ededdadabe07d2aa`) passed maintainer-run
|
||||
executed Linux and native Windows matrices with Go 1.25.12 and Go 1.26.5. The
|
||||
tested golden output had the same SHA-256 on each tested host:
|
||||
`63fa75a3049a3a8a12d769d7f9b6b510dfe763baacf706775b75cef2c57a984f`.
|
||||
|
||||
That commit is a pre-beta baseline, not evidence for the later Beta 1 commit.
|
||||
The required matrix must be rerun from the exact candidate before its tags are
|
||||
published. Native macOS execution remains pending and is explicitly provisional
|
||||
for this beta.
|
||||
The complete release preflight passed, including race tests, bounded parser
|
||||
fuzz smoke, known-vulnerability analysis of both zero-third-party-dependency
|
||||
modules, candidate-version provenance, and six cross-builds. The signed runtime
|
||||
and compiler tags were published in that order. Fresh direct and public-proxy
|
||||
runtime-first installs passed after normal proxy propagation. Native macOS
|
||||
execution remains pending and is explicitly provisional for this beta.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- Source syntax, generated format, CLI details, and runtime API may change
|
||||
before final v1.
|
||||
- Native macOS behavior has not yet been maintainer-validated.
|
||||
- In a shared fresh Go module cache, add the nested `sando` runtime before
|
||||
installing the parent compiler module at the same Beta 1 version.
|
||||
- Prebuilt binary artifacts, checksums, SBOMs, reproducible archives,
|
||||
key-recovery rehearsal, systematic browser differential, long fuzz,
|
||||
benchmark, and final compatibility gates remain work toward the release
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"schema_version":2,"project":"sandwich-hime","export_policy":"exact-allowlist-v1","export_mode":"release","file_count":80,"allowlist_sha256":"e40a56b3431efdd99b3a78c38c387722de347216640383fc849569a817edf5c6","manifest_sha256":"93870a8c1e91602754de257c30249892821356ea9bf8d89e1b4502b62d769b06"}
|
||||
{"schema_version":2,"project":"sandwich-hime","export_policy":"exact-allowlist-v1","export_mode":"release","file_count":90,"allowlist_sha256":"393ee598dc7e12cdbb603887bf06599e46b40d7c19e4ff693a818cc32afb01ec","manifest_sha256":"655bfadbddf5967288147f5b1762debd3f4b106d86c044706c41cf6aabca5e00"}
|
||||
|
||||
+28
-18
@@ -2,7 +2,7 @@
|
||||
658ba4b4645426f8c3249337f47669074ae9249a31703dcd9ea4c1afec45e20b ./.gitattributes
|
||||
d5ae411fb422b2388cac220f9655900eecbc49ece961b2bb2a6610347733b756 ./.gitignore
|
||||
98f663ab0f376b4550094465ec2e06192d1e0b0707604ec6794f20b0d10952c1 ./AI_CONTRIBUTIONS.md
|
||||
8bc6c7586a58bdc648c7d3df2db33fa6db6381a111da9d8fc2d5817b5e0517ad ./CHANGELOG.md
|
||||
0828545d3aa440e1ec5dce4b934de6800413f55a925988b60923c5ee9b700a4e ./CHANGELOG.md
|
||||
b696cab3cf482ff5737501371cca749369b119351383e698ced42bcdbcbfc8ae ./CLA.md
|
||||
797e884105738fc931b585b695424f43ec5f296d8ab9bba5191b096e87a9e2c2 ./CONTRIBUTING.md
|
||||
86d7e49d5d90e0f98a4ad0f14b5d8b9f11ed09a1e29ecdf27388316b28e195e8 ./COPYRIGHT
|
||||
@@ -13,35 +13,38 @@ a4570d054f072d33b8f17b0c8b162a6ee0ca37d7df2b1aee7e4b728ab350a892 ./GOVERNANCE.m
|
||||
47d857e49f89596bac9b09fc8ca57a668a33d01e2b51508acfc92ed321cdc27f ./LICENSES.md
|
||||
b6aa08e5ccaec3c5dccdc19d7cd7f54a70adae4d57966263c7aa353c7ba70e08 ./MAINTAINERS.md
|
||||
6638db2f1fba831c79de835ce95c847a5b36c5b5c693b99a28655b2d096cc440 ./OUTPUT_EXCEPTION.md
|
||||
dda0ffee17bc1586ac16cc9707999cde2909a116c98cd43aeab85e0d3da7b636 ./README.md
|
||||
9895823f9f7ba88ddf048c53c23ed32298b151ab049a9c4470366389d9498ff4 ./RELEASE.md
|
||||
6d1e49dd72ba9592a3fd0562fcca1857803a3dd86dc097b36043e20f5d5cd591 ./ROADMAP.md
|
||||
50a24995b39a957e47e0e181f8771a87141302b4d308fa8d8bfdc2e70121c2b9 ./SECURITY.md
|
||||
c3ac873ae2e6248e1d86dd542a11557b24b8dba80e4785f3bc1018152020235c ./README.md
|
||||
2751674c180f15a42c1d2b40cf149be4138aa6cf247d7be176f1f0c468103c24 ./RELEASE.md
|
||||
209decb6769646eb2f58e312fbcd9c497c26234f3d3115bae3f20493b8178584 ./ROADMAP.md
|
||||
0fef473ac46b71215d1eb7922da4594210ffbbb8bb2dedd5e531fb3bd09396e1 ./SECURITY.md
|
||||
53bd6eda804d6b782bdb07115ec197c890813cf2d5d0125dfe8f47f5f92f75b0 ./SPEC.md
|
||||
3d9e680cdfe147df7cc9ff29ecf1d3e566e9cd559ae84db4880e559b9c7c7205 ./TRADEMARKS.md
|
||||
136a6d82db842547b342f8b0c9ffdc7c04f7c9b473b4ef6dca9dbc940cb24b54 ./cmd/himesan/main.go
|
||||
3011a435aaeb572d34e2d2b582865fd63268c92a9ae3f0ae1a56d1a59d83cd43 ./cmd/himesan/main_test.go
|
||||
8cd8db68e1300f9b78cc7235855853cbc7aeb499a921419e23a22e4d22826fcb ./cmd/himesan/main.go
|
||||
0709990e28d58d50e49219690e435338b269284efb6c2d2e2c95ed6b242580df ./cmd/himesan/main_test.go
|
||||
1ecbba46f8b1b2d548a01d7e98afae17b2dd17a814338ff1f88db885655d1c07 ./docs/ARCHITECTURE.md
|
||||
9c598559a89fa4a9bdd2311bd1ed8330992d0a0f74ec8b29ac151fc0ff8fef16 ./docs/BENCHMARKS.md
|
||||
5c3a62fed80ca28d56558b8c75e8b5be8ba7d2554127adf4609d96da314e85b0 ./docs/BRAND.md
|
||||
4f7b04b3f74a2e90fc69019cad78a1287f4806ae84e7207cfdf000971702395d ./docs/COMPATIBILITY.md
|
||||
2b815d3b815b8d338560183c6f0af46f761c2465309783c93870b8ac8d022d03 ./docs/COMPATIBILITY.md
|
||||
5f4ac209a16ab110baeaa64a40c19d9239c903e17550c3f05e1e1473ddcc33a3 ./docs/DEVELOPMENT_SERVER.md
|
||||
51aa57a81131b64f76c45552122de842f22be92d81c8bba8f6fd38a18a7670d6 ./docs/DIAGNOSTICS.md
|
||||
9bd43853d91b841c4879dac94dda5aafefa25bcdf4a02763445e2506fc618b03 ./docs/SECURITY_EVIDENCE.md
|
||||
a62cc7174f3c92d8ef77e4bd9607fbf5d4b80bc514ff05bd433c02a9b0578f18 ./docs/LANGUAGE_SERVER.md
|
||||
965a6ae57a8162c3af81f4987771e88617247f903088d210720ea2afab152cc9 ./docs/SECURITY_EVIDENCE.md
|
||||
d969c7b5486ee93e54232fd69d9db06f3b4dc1bba63001596ec48545073c2680 ./docs/THREAT_MODEL.md
|
||||
82107c57043af40b9e3ec03f4ed9efddcf3bcdda1765b99ec413f835be4a46e3 ./docs/V1_RELEASE_PLAN.md
|
||||
738258ba8f7e5ffea67d3f00eb70839590171971a9946a55b013ca95baf7aafb ./docs/V1_RELEASE_PLAN.md
|
||||
f27c46ca63707bb8cc570eab1ea521824e94bc59b1d153998a5e91c2c7340d16 ./go.mod
|
||||
ca0bf5051d356d2602f46201fb1637ce48b629ad42161877eec13f743f215dc5 ./internal/compiler/abi_test.go
|
||||
d891b9b075617050471b2ca34de73d926aaebde4ec638a5039b0d5001d3172f4 ./internal/compiler/analysis.go
|
||||
c123bf8c8fd847962e147041172b71fef7e852819ccd7b27d4648748e02d522f ./internal/compiler/analysis_test.go
|
||||
6ef6a0f15a5aca1c8708cbf24218372e1fca9c6fead1a5a75d261faa69651af7 ./internal/compiler/backend.go
|
||||
4d309392a7739b26e41cd9d9db71b52077bb8198573634ddf793ad531acc81cf ./internal/compiler/compiler_test.go
|
||||
dbba23e360bd6dd1e8f42953a49a7cfcc241aa3ac76f5ce505ec8f8558833c84 ./internal/compiler/context.go
|
||||
b2a96ef1ad572ad9cd0e9247328ca261de6f9f3689da41e3f3e111d405a6dee6 ./internal/compiler/diagnostic.go
|
||||
45562a41ef9ab1116746e4962ce8f93d4d8651e1e468a38122c626f8a34a2874 ./internal/compiler/discover.go
|
||||
6a3dccd0c7caf92fe2690b350f1db6f34037609b1fff6d3e68e0e229241950e5 ./internal/compiler/discover.go
|
||||
50a8bad11deea99faf0c7c9de1266bc086b7a1247ff9dd275ffedfc69a3be81d ./internal/compiler/e2e_test.go
|
||||
eefb05a35bd07660a293c8af97949cd6f69a22709728f3fe2cc9132b863b7d5a ./internal/compiler/fuzz_test.go
|
||||
d166096f185d76b2698aa3ab3251f00e58f84cdedc3af667e88ddd528ca0cb76 ./internal/compiler/model.go
|
||||
440580fcd844b2c80b1f4dddc4d83db82974e203cca298387a190e53e6a524f3 ./internal/compiler/model.go
|
||||
01516c0330b14a1970019690d8efcc9004d2dcfa59e82fef1b67f98005b67532 ./internal/compiler/operation.go
|
||||
d7d8181455d5f37ef9bcc6bdbf86e0630f20e8a5b3b81688d12742687b434c99 ./internal/compiler/parse.go
|
||||
1f633caa019d8de9ae5bbad27e29679fcc01b3cb9a33d7d616be07140e06f38b ./internal/compiler/parse.go
|
||||
80cf170514a3b955d24440cb086d34e19f3a305510e3c5db95cb897be91f922a ./internal/compiler/replace_unix.go
|
||||
0fff1c67447bf5353ed1df6e7dfc4b14581b67adc1bf02f7a4a7c1f2680c392f ./internal/compiler/replace_windows.go
|
||||
f4ba01010ed5f5ba1e979702d82e95312bc0a4b13cc205c098926839be4ecb73 ./internal/compiler/testdata/golden/basic.sando
|
||||
@@ -58,11 +61,18 @@ c0f76ef5c14b0a28ed1e68d8d518102ffcbf067285b087eed4d13cd3c87b0e00 ./internal/dev
|
||||
7f1efbefea3a277f0f4d96a29219293efd78d9dc44823c09b9667b19d5042047 ./internal/devserver/proxy.go
|
||||
aebf8388576d7bc9b047ceedf8a893acb3ace5fe16f44cb883efe63eef072ef9 ./internal/devserver/proxy_test.go
|
||||
e6561e693138a3b77be06c1a98999e71494bbca0d0c72ccb9bff57b8e8575c0f ./internal/devserver/supervisor.go
|
||||
b94103cd4b582968cdb0b61b0164f57ade006fa4e5187fcaa05944274192526a ./internal/devserver/supervisor_test.go
|
||||
5c0ce1eedb08c0381dd048526e9f781a2ac8501cc1142648c43d4aa07ca62299 ./internal/devserver/supervisor_test.go
|
||||
e0a682c0153bf4f2a1f26cc6095d7893ad96e6199cbe76d0150785fc996f1141 ./internal/devserver/watch.go
|
||||
b7a7fabf9a6c497f7ac2262628c5fb37a6bd00da676e1b7d5088d5f649c9f14c ./internal/devserver/watch_test.go
|
||||
76609ee5c57b1c174076689d4953e6ec5ef914ca114c27f1ca1d6e7fcabbddcf ./internal/lsp/features.go
|
||||
edd377bca36a04ae76620a146f986e25c954fd137bab0b0e49084dbcde216ce7 ./internal/lsp/protocol.go
|
||||
73b2ff8d6fd8fe44abec287384f0051520fe9564a50b4764538474f336624eee ./internal/lsp/protocol_test.go
|
||||
2a2151a0211a5048c3bc89f9a2901a3c208609f483e99144f3837f87941efff9 ./internal/lsp/security_test.go
|
||||
3be838b4bb0b618f46c0eab40788a1e03121da25644dedfa7542bd0fa64571e1 ./internal/lsp/server.go
|
||||
c919b132373d9ab715cdaf057789a3d23f27b1c9f0eded14c06d04d2a4c86ee3 ./internal/lsp/server_test.go
|
||||
4c5ade5f649dffdd0a1b0a350c15488fc601b7eba582386f39e64412387b5c91 ./internal/lsp/workspace.go
|
||||
d8c6f37c94ef426fc2d95c82331265f7d700d2e2a23100ad78c92849280ff6d8 ./internal/version/version.go
|
||||
9b615038a1178eedbcab299d90548e259ac34d2f5c51deadd79ed16f51ef6911 ./internal/version/version_test.go
|
||||
a1ee0ec44dc45f01a3357cf93e13a81587af7f061497c25df374e2a88231f3b7 ./internal/version/version_test.go
|
||||
e8a3026ec920d7312f843e2001e50ae4e34fd1ba5f9b2ae25a6113de1fa88385 ./sando/COPYRIGHT
|
||||
c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 ./sando/LICENSE
|
||||
b4a7bffe678a97209881e07989563a5085aa0ead9e1b67306087dac6b97bad70 ./sando/README.md
|
||||
@@ -72,9 +82,9 @@ ff76daee5b642ad84af31701833246d68b54d09580192312d750a7a2e893a692 ./sando/go.mod
|
||||
80ff53787919e809b8085d6ad9c3e183c9c7c1d74cfeda73369ac5c4607c236f ./sando/trust.go
|
||||
85621a44c730582f4410ac2c70418b739fb55e916f7e6b73a1a619982c459572 ./sando/write.go
|
||||
b188917e258890e6b6e4840a6fd946fc9a77cabc2068da3764f221e4a6a5df97 ./sando/write_test.go
|
||||
504897b29686e0ea7adff8beb8ec91612df3ee169397309c3b6b69eb0393491b ./scripts/README.md
|
||||
c4a161faba46ce5b508c0788078256a520277a573a3ace0e85ae0c26b16d298b ./scripts/README.md
|
||||
0bc796f71c863aa898674a26c56f055e3d81cf20629ca7b32fbae87d8841e0a8 ./scripts/check-licenses.sh
|
||||
6c73ad46beb642836ae4d462f40e7ecc8d86a3cc194e71a5d0859dac73af0410 ./scripts/release-check.sh
|
||||
9cd43005a7d0f3659b11c5c14e4e0b9e7f675b695da185f4ee97c54edebf0dc6 ./scripts/verify-public-install.sh
|
||||
1b003062799b99bfe271b47438397a8cce5875c60c982a0117eb11c3babcadf0 ./scripts/release-check.sh
|
||||
78a64c7fb3a039b15a1d08b4c0b873952852287a07f670247b081e59dbb09a30 ./scripts/verify-public-install.sh
|
||||
24ed3c9a1d37e46a856cbbd68e5c58ae04c6c9852902b99ed675e1f428339a9f ./scripts/verify.ps1
|
||||
f0cbd86759fa729064cb1c69991db2ac291792dadb6b1e1ba83794f2e390404d ./scripts/verify.sh
|
||||
|
||||
@@ -42,30 +42,41 @@ request object, or production server.
|
||||
|
||||
## Status
|
||||
|
||||
`v1.0.0-beta.1` is the first installable public beta. It is supported for
|
||||
`v1.0.0-beta.2` is the current compiler beta. It is supported for
|
||||
learning, classroom projects, evaluation, and compatibility feedback. It is a
|
||||
semantic-version prerelease: source syntax, generated output, the runtime API,
|
||||
and CLI behavior may change before final v1, and this beta is not recommended
|
||||
for production deployment.
|
||||
|
||||
Maintainer-run testing has established a pre-beta baseline on native Windows
|
||||
and on Linux with Go 1.25 and Go 1.26. That matrix must pass again on the exact
|
||||
Beta 1 commit before its tags are published. Native macOS validation is still
|
||||
The exact Beta 1 source passed maintainer-run native Windows and executed Linux
|
||||
matrices with Go 1.25.12 and Go 1.26.5. Native macOS validation is still
|
||||
pending, so macOS support is provisional in this beta. Mac learners and Go
|
||||
developers are warmly invited to try it and share their macOS version,
|
||||
architecture, Go version, command, and smallest useful reproduction. Community
|
||||
reports broaden the evidence; maintainers remain responsible for security
|
||||
review, triage, fixes, and release decisions.
|
||||
|
||||
Install the beta compiler:
|
||||
Inside an application module, add the small runtime first:
|
||||
|
||||
```sh
|
||||
go install gamertan.com/sandwich-hime/cmd/himesan@v1.0.0-beta.1
|
||||
go get gamertan.com/sandwich-hime/sando@v1.0.0-beta.1
|
||||
```
|
||||
|
||||
Add the small runtime to an application module:
|
||||
Then install the current beta compiler:
|
||||
|
||||
```sh
|
||||
go install gamertan.com/sandwich-hime/cmd/himesan@v1.0.0-beta.2
|
||||
```
|
||||
|
||||
Keep that runtime-first order. It avoids path-selection ambiguity between the
|
||||
parent compiler module and its independently tagged nested runtime.
|
||||
|
||||
If the compiler was installed first and `go get` reports that the parent module
|
||||
does not contain `sando`, seed the exact nested module without clearing the
|
||||
global cache, then retry:
|
||||
|
||||
```sh
|
||||
go mod download gamertan.com/sandwich-hime/sando@v1.0.0-beta.1
|
||||
go get gamertan.com/sandwich-hime/sando@v1.0.0-beta.1
|
||||
```
|
||||
|
||||
@@ -73,13 +84,14 @@ For a reproducible one-off or classroom invocation that does not depend on the
|
||||
learner's `PATH`:
|
||||
|
||||
```sh
|
||||
go run gamertan.com/sandwich-hime/cmd/himesan@v1.0.0-beta.1 --help
|
||||
go run gamertan.com/sandwich-hime/cmd/himesan@v1.0.0-beta.2 --help
|
||||
```
|
||||
|
||||
The runtime is released first as `sando/v1.0.0-beta.1`; the compiler follows as
|
||||
`v1.0.0-beta.1`. If a newly announced version is not immediately available
|
||||
through a module proxy, retry after the proxy has discovered the immutable tag
|
||||
or use the canonical Gitea release instructions.
|
||||
The unchanged runtime remains `sando/v1.0.0-beta.1`; compiler versions are
|
||||
independent. Beta 2 adds `himesan lsp --stdio` without changing the runtime ABI.
|
||||
Signed compiler tags, direct fetching, the public Go proxy, and the checksum
|
||||
database are verified after publication. A newly announced version may still
|
||||
need a short propagation interval before every proxy sees its immutable tag.
|
||||
|
||||
For repository development:
|
||||
|
||||
@@ -97,6 +109,14 @@ and serves it through `http://127.0.0.1:7331` with local-only reload
|
||||
diagnostics. That is a Cole-shaped convenience, not a production server or a
|
||||
requirement. Take the paved path—or don't.
|
||||
|
||||
Beta 2 also provides a standard, editor-neutral language server. It analyzes
|
||||
unsaved overlays with the compiler's real parser and context model, but never
|
||||
generates, runs Go, executes a project, fetches a module, accesses the network,
|
||||
or starts the dev supervisor. See
|
||||
[the language-server contract](docs/LANGUAGE_SERVER.md). The portable Agent
|
||||
Skill and VS Code preview live in the separate
|
||||
[tooling repository](https://gitea.speelman.ca/gamertan/sandwich-hime-tooling).
|
||||
|
||||
Final-v1 installs will use the same paths with `@v1.0.0`. A version is
|
||||
advertised as available only after its immutable tags, `gamertan.com`
|
||||
metadata, and clean direct-fetch installation have been verified.
|
||||
|
||||
+42
-10
@@ -68,14 +68,45 @@ Before `sando/v1.0.0-beta.1` and `v1.0.0-beta.1` are created:
|
||||
generated fixtures are current under that exact binary.
|
||||
7. Create signed annotated tags and publish the runtime tag first, then the
|
||||
compiler tag, from the same reviewed commit.
|
||||
8. Verify both documented installs from fresh `GOPROXY=direct` and public-proxy
|
||||
caches. Record propagation delays as delays, not test passes.
|
||||
8. Verify both documented runtime-first installs from fresh `GOPROXY=direct`
|
||||
and public-proxy caches. Record propagation delays as delays, not test
|
||||
passes.
|
||||
|
||||
The passing public commit
|
||||
`113c95c21e57227b4675c9fda015ada59cc9e9a6` (tree
|
||||
`a2aeb4dac22853cb3894e3e487b94bbeff5051e5`) is only the pre-beta
|
||||
platform baseline. Any documentation, versioning, or code change produces a new
|
||||
candidate and requires the candidate matrix to run again before tagging.
|
||||
Beta 1 was published from public commit
|
||||
`b7a84054d755e42285e50298e41e47f06a8325a5` (tree
|
||||
`be9e118e38dfebed19f60403ededdadabe07d2aa`) after its exact-candidate
|
||||
matrix passed. Future prereleases require their own candidate evidence; this
|
||||
result cannot be relabeled for another commit.
|
||||
|
||||
## Beta 2 compiler publication gates
|
||||
|
||||
`v1.0.0-beta.2` is compiler-only. Do not create or move a runtime tag when the
|
||||
`sando` subtree is byte-identical to `sando/v1.0.0-beta.1`.
|
||||
|
||||
Run its technical preflight with the retained runtime made explicit:
|
||||
|
||||
```sh
|
||||
./scripts/release-check.sh \
|
||||
--version v1.0.0-beta.2 \
|
||||
--runtime-tag sando/v1.0.0-beta.1
|
||||
```
|
||||
|
||||
In addition to every Beta 1 compiler/security/determinism gate:
|
||||
|
||||
1. verify protocol framing, malformed messages, shutdown, request
|
||||
cancellation, UTF-16 positions, CRLF/NUL handling, bounded fuzz smoke,
|
||||
overlays, source appearance/deletion, duplicates, cycles, symlink and
|
||||
nested-module boundaries, completion scope, and component definitions;
|
||||
2. prove the language-server package does not write, execute project code,
|
||||
invoke Go, fetch, access the network, or start the development supervisor;
|
||||
3. run the exact candidate on supported Go lines under executed Linux and
|
||||
native Windows, with native macOS status stated explicitly;
|
||||
4. build an exact version-stamped candidate and assert the additive
|
||||
`features: ["lsp-stdio"]` JSON identity;
|
||||
5. publish a signed annotated compiler tag only after the reviewed sanitized
|
||||
public commit is current; and
|
||||
6. verify direct, public-proxy, and checksum-database installation without
|
||||
altering `v1.0.0-beta.1` or `sando/v1.0.0-beta.1`.
|
||||
|
||||
## RC and final gates
|
||||
|
||||
@@ -110,9 +141,10 @@ metadata exist, run:
|
||||
scripts/verify-public-install.sh --version vX.Y.Z
|
||||
```
|
||||
|
||||
That check exercises the documented `go install` and `go get` commands from
|
||||
fresh direct-fetch and public-proxy caches. It is separate from the pre-tag,
|
||||
read-only `scripts/release-check.sh`.
|
||||
That check adds the nested runtime before installing the parent compiler, then
|
||||
exercises both commands from fresh direct-fetch and public-proxy caches. The
|
||||
order avoids the Go module-cache ambiguity documented for Beta 1. It is
|
||||
separate from the pre-tag, read-only `scripts/release-check.sh`.
|
||||
|
||||
Release notes report hardware, commit, datasets, commands, `ns/op`,
|
||||
allocations, response latency, and methodology for any performance claim.
|
||||
|
||||
+4
-3
@@ -17,11 +17,12 @@ it is not a production-stability promise.
|
||||
- [x] Establish a public pre-beta Linux/Windows matrix on Go 1.25 and Go 1.26.
|
||||
- [x] Document macOS as provisional and invite useful community reports while
|
||||
retaining maintainer responsibility for security and releases.
|
||||
- [ ] Rerun all required Windows/Linux checks and deterministic generation on
|
||||
- [x] Rerun all required Windows/Linux checks and deterministic generation on
|
||||
the exact Beta 1 candidate.
|
||||
- [ ] Publish immutable `sando/v1.0.0-beta.1`, then
|
||||
- [x] Publish immutable `sando/v1.0.0-beta.1`, then
|
||||
`v1.0.0-beta.1`, from the reviewed public commit.
|
||||
- [ ] Verify clean direct and public-proxy installs after publication.
|
||||
- [x] Verify clean runtime-first direct and public-proxy installs after
|
||||
publication.
|
||||
- [ ] Complete native macOS maintainer validation. This is an RC/final gate,
|
||||
not a Beta 1 gate.
|
||||
|
||||
|
||||
+12
-5
@@ -2,10 +2,11 @@
|
||||
|
||||
# Security policy
|
||||
|
||||
Sandwich Hime `v1.0.0-beta.1` is supported within a deliberately narrow
|
||||
scope: learning, classroom projects, evaluation, and compatibility testing.
|
||||
Sandwich Hime compiler `v1.0.0-beta.2` and runtime
|
||||
`sando/v1.0.0-beta.1` are supported within a deliberately narrow scope:
|
||||
learning, classroom projects, evaluation, and compatibility testing.
|
||||
Security reports are welcome and receive best-effort maintainer assessment and
|
||||
fixes while Beta 1 is the current prerelease. This is not production support,
|
||||
fixes while this pair is current. This is not production support,
|
||||
an SLA, a fitness guarantee, or a promise that a fix will preserve beta APIs.
|
||||
|
||||
The community is invited to help find compatibility gaps, especially on macOS.
|
||||
@@ -17,7 +18,7 @@ advisories, and release decisions.
|
||||
|
||||
| Version | Security status |
|
||||
| --- | --- |
|
||||
| `v1.0.0-beta.1` and `sando/v1.0.0-beta.1` | Current evaluation/classroom prerelease once published; best-effort security assessment and fixes; interfaces may change |
|
||||
| `v1.0.0-beta.2` compiler and `sando/v1.0.0-beta.1` runtime | Current evaluation/classroom prerelease pair; best-effort security assessment and fixes; interfaces may change |
|
||||
| Public `main` | Development source; reports welcome, but no compatibility or production-support promise |
|
||||
| Older prereleases | Superseded when a newer prerelease or final version is published; reports are still triaged to determine affected versions |
|
||||
|
||||
@@ -133,7 +134,13 @@ an independent security audit, certification, or formal verification. Coverage
|
||||
percentages, passing scanners, and a clean vulnerability database result are
|
||||
evidence of specific checks—not proof that no vulnerability exists.
|
||||
|
||||
Beta 1 publication requires signed annotated source tags, but may precede the
|
||||
The Beta 2 language server is read-only and standard-library-only. Its tests
|
||||
exercise bounded framing, malformed messages, cancellation, UTF-16 positions,
|
||||
unsaved overlays, workspace re-indexing, and no generated writes. Source review
|
||||
forbids process execution and network-client imports inside the server. Editor
|
||||
workspace trust and executable resolution remain editor-client responsibilities.
|
||||
|
||||
Beta 1 uses signed annotated source tags, but precedes the
|
||||
complete prebuilt-artifact and key-recovery system. Signed binaries, checksums,
|
||||
an SBOM, reproducible archives, and complete source/build provenance are
|
||||
release-candidate and final-v1 gates. Their absence from a source-only beta must
|
||||
|
||||
+27
-1
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/compiler"
|
||||
"gamertan.com/sandwich-hime/internal/devserver"
|
||||
"gamertan.com/sandwich-hime/internal/lsp"
|
||||
"gamertan.com/sandwich-hime/internal/version"
|
||||
)
|
||||
|
||||
@@ -38,6 +39,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
return runCompilerCommand(ctx, args[0], args[1:], stdout, stderr, compiler.Check)
|
||||
case "dev":
|
||||
return runDev(ctx, args[1:], stdout, stderr)
|
||||
case "lsp":
|
||||
return runLSP(ctx, args[1:], os.Stdin, stdout, stderr)
|
||||
case "version":
|
||||
return runVersion(args[1:], stdout, stderr)
|
||||
case "help", "-h", "--help":
|
||||
@@ -119,7 +122,8 @@ func runVersion(args []string, stdout, stderr io.Writer) int {
|
||||
Compiler string `json:"compiler"`
|
||||
RuntimeABI string `json:"runtime_abi"`
|
||||
Go string `json:"go"`
|
||||
}{Compiler: version.Compiler, RuntimeABI: version.RuntimeABI, Go: runtime.Version()}
|
||||
Features []string `json:"features"`
|
||||
}{Compiler: version.Compiler, RuntimeABI: version.RuntimeABI, Go: runtime.Version(), Features: []string{"lsp-stdio"}}
|
||||
if *jsonOutput {
|
||||
if err := json.NewEncoder(stdout).Encode(information); err != nil {
|
||||
fmt.Fprintf(stderr, "himesan: encode version: %v\n", err)
|
||||
@@ -131,6 +135,27 @@ func runVersion(args []string, stdout, stderr io.Writer) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func runLSP(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("lsp", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
stdio := flags.Bool("stdio", false, "serve Language Server Protocol JSON-RPC over stdin/stdout")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return 0
|
||||
}
|
||||
return 2
|
||||
}
|
||||
if !*stdio || flags.NArg() != 0 {
|
||||
fmt.Fprintln(stderr, "himesan lsp requires exactly --stdio")
|
||||
return 2
|
||||
}
|
||||
if err := lsp.Run(ctx, lsp.Options{Input: stdin, Output: stdout, LogOutput: stderr}); err != nil {
|
||||
fmt.Fprintf(stderr, "himesan lsp: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type stringList []string
|
||||
|
||||
func (values *stringList) String() string { return strings.Join(*values, ",") }
|
||||
@@ -312,6 +337,7 @@ func printHelp(output io.Writer) {
|
||||
fmt.Fprintln(output, " himesan check [--json] [paths...] validate sources and committed output without writes")
|
||||
fmt.Fprintln(output, " himesan bless [--json] [paths...] friendly read-only alias for check")
|
||||
fmt.Fprintln(output, " himesan dev [flags] [package] [-- app-args...] run the loopback last-good supervisor")
|
||||
fmt.Fprintln(output, " himesan lsp --stdio run the read-only language server")
|
||||
fmt.Fprintln(output, " himesan version [--json] print compiler and runtime ABI versions")
|
||||
fmt.Fprintln(output)
|
||||
fmt.Fprintln(output, "Templates use .sando; .san remains exclusively San language source.")
|
||||
|
||||
@@ -30,14 +30,24 @@ func TestRunHelpVersionAndUnknownCommand(t *testing.T) {
|
||||
if code := run(context.Background(), []string{"version", "--json"}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("version exit code = %d: %s", code, stderr.String())
|
||||
}
|
||||
var versionResult map[string]string
|
||||
var versionResult struct {
|
||||
Compiler string `json:"compiler"`
|
||||
RuntimeABI string `json:"runtime_abi"`
|
||||
Features []string `json:"features"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &versionResult); err != nil {
|
||||
t.Fatalf("version JSON: %v", err)
|
||||
}
|
||||
if versionResult["compiler"] == "" || versionResult["runtime_abi"] != compiler.RuntimeABI {
|
||||
if versionResult.Compiler == "" || versionResult.RuntimeABI != compiler.RuntimeABI || len(versionResult.Features) != 1 || versionResult.Features[0] != "lsp-stdio" {
|
||||
t.Fatalf("version result = %#v", versionResult)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(context.Background(), []string{"lsp"}, &stdout, &stderr); code != 2 {
|
||||
t.Fatalf("lsp without --stdio exit code = %d, want 2", code)
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(context.Background(), []string{"rebuke"}, &stdout, &stderr); code != 2 {
|
||||
|
||||
+11
-11
@@ -2,9 +2,9 @@
|
||||
|
||||
# Compatibility policy
|
||||
|
||||
## Beta 1 contract
|
||||
## Beta contract
|
||||
|
||||
`v1.0.0-beta.1` and `sando/v1.0.0-beta.1` are semantic-version
|
||||
Compiler `v1.0.0-beta.2` and runtime `sando/v1.0.0-beta.1` are semantic-version
|
||||
prereleases. They are supported for learning, classroom projects, evaluation,
|
||||
and compatibility testing. Before final v1, source syntax, generated output,
|
||||
the runtime API, CLI behavior, diagnostics, and configuration may change
|
||||
@@ -32,29 +32,29 @@ payloads before final v1, or hand-edited generated files.
|
||||
|
||||
## Go and platform support
|
||||
|
||||
Beta 1 targets Go 1.25 and Go 1.26. Support is based on point-in-time,
|
||||
The current beta targets Go 1.25 and Go 1.26. Support is based on point-in-time,
|
||||
maintainer-run release matrices, not an implication of continuous CI coverage.
|
||||
A Go support change is announced in release notes before it takes effect.
|
||||
|
||||
The current public evidence is a **pre-beta baseline** on commit
|
||||
`113c95c21e57227b4675c9fda015ada59cc9e9a6` (tree
|
||||
`a2aeb4dac22853cb3894e3e487b94bbeff5051e5`):
|
||||
The current public evidence is the exact Beta 1 source at commit
|
||||
`b7a84054d755e42285e50298e41e47f06a8325a5` (tree
|
||||
`be9e118e38dfebed19f60403ededdadabe07d2aa`):
|
||||
|
||||
| Platform | Go lanes | Maintainer-run result |
|
||||
| --- | --- | --- |
|
||||
| Windows 11/amd64 on NTFS | 1.25.12, 1.26.5 | Native tests, race, vet, builds, generation, process cleanup, watcher boundaries, and temporary consumer compilation passed; privileged symlink and POSIX-only permission cases were not exercised |
|
||||
| Linux/amd64 on WSL2 with an ext4 checkout | 1.25.12, 1.26.5 | Tests, race, vet, builds, generation, focused filesystem/development cases, and license checks passed |
|
||||
| Linux/amd64 in isolated containers on a Linux server | 1.25.12, 1.26.5 | Tests, race, vet, builds, deterministic generation, and license checks passed |
|
||||
| Linux/amd64 in isolated containers on a Linux server | 1.25.12, 1.26.5 | The earlier pre-beta baseline passed tests, race, vet, builds, deterministic generation, and license checks; this was not rerun on the exact Beta 1 commit |
|
||||
| macOS | — | Native maintainer validation pending; provisional for Beta 1 |
|
||||
|
||||
The golden generated file had SHA-256
|
||||
`63fa75a3049a3a8a12d769d7f9b6b510dfe763baacf706775b75cef2c57a984f`
|
||||
on every tested Windows and Linux lane.
|
||||
|
||||
Because the Beta 1 candidate contains changes after that public baseline, the
|
||||
complete Windows/Linux matrix must be rerun on the exact candidate before the
|
||||
tags are published. The baseline does not become beta evidence merely because
|
||||
its code is nearby in history.
|
||||
The signed Beta tags and fresh direct/public-proxy installation were verified
|
||||
after publication. For Beta 1, add the nested runtime to an application module
|
||||
before installing the parent compiler at the same version; this avoids a Go
|
||||
module-cache path-selection ambiguity observed in the reverse order.
|
||||
|
||||
## macOS feedback
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<!-- SPDX-License-Identifier: AGPL-3.0-only -->
|
||||
|
||||
# Hime-san language server
|
||||
|
||||
Hime-san `v1.0.0-beta.2` adds a reusable, read-only Language Server Protocol
|
||||
surface:
|
||||
|
||||
```sh
|
||||
himesan lsp --stdio
|
||||
```
|
||||
|
||||
The server accepts one local workspace root per process. Editors with multiple
|
||||
workspace folders start one process for each folder. Standard output contains
|
||||
only framed JSON-RPC; bounded operational messages go to standard error and do
|
||||
not include template source, environment values, or secrets.
|
||||
|
||||
## Beta 2 capabilities
|
||||
|
||||
- full-document synchronization and unsaved in-memory overlays;
|
||||
- live compiler diagnostics, trust warnings, duplicate components, and
|
||||
statically knowable component cycles;
|
||||
- UTF-16 protocol positions without changing compiler CLI byte coordinates;
|
||||
- hover help for tags, inferred output contexts, component signatures, and
|
||||
trusted-output boundaries;
|
||||
- document symbols for the declared component and template regions;
|
||||
- delimiter/tag completion plus same-package and already-imported component
|
||||
completion; and
|
||||
- component go-to-definition.
|
||||
|
||||
The index honors the compiler's symlink, nested-module, VCS, vendor, and
|
||||
filesystem boundaries. Open/save is analyzed immediately; ordinary edits are
|
||||
debounced for 200 ms and superseded analyses are canceled. Appearance,
|
||||
deletion, rename, and save notifications rebuild the bounded source index.
|
||||
|
||||
## Deliberate exclusions
|
||||
|
||||
The language server does not generate files, report generated-file freshness,
|
||||
invoke Go or `gopls`, execute project code, fetch dependencies, access the
|
||||
network, or start `himesan dev`. It provides no general Go or HTML completion,
|
||||
formatting, rename, references, automatic imports, or live browser preview.
|
||||
|
||||
Use explicit `himesan check --json` for committed-output freshness and normal
|
||||
Go tests/builds for type checking. Editors remain responsible for workspace
|
||||
trust, process startup, and user-visible command policy.
|
||||
|
||||
## Resource limits
|
||||
|
||||
Protocol frames and individual documents are limited to 16 MiB, the indexed
|
||||
workspace source set to 64 MiB and 10,000 `.sando` files. These are denial-of-
|
||||
service guardrails for trusted local workspaces, not a sandbox for hostile
|
||||
template authors.
|
||||
+31
-31
@@ -11,23 +11,20 @@ verification, or guarantee that no vulnerability exists.
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Assessment date | 2026-08-12 |
|
||||
| Evidence sets | Clean security self-assessment plus an exact-commit pre-beta platform baseline; neither is evidence for the later Beta 1 candidate |
|
||||
| Public commit | `113c95c21e57227b4675c9fda015ada59cc9e9a6` |
|
||||
| Public tree | `a2aeb4dac22853cb3894e3e487b94bbeff5051e5` |
|
||||
| Maintainer-run environments | Windows 11/amd64 on NTFS; Ubuntu 20.04/amd64 under WSL2 on ext4; Linux/amd64 server containers |
|
||||
| Evidence sets | Clean security self-assessment plus exact-commit Beta 1 platform, release, signing, and installation checks |
|
||||
| Public commit | `b7a84054d755e42285e50298e41e47f06a8325a5` |
|
||||
| Public tree | `be9e118e38dfebed19f60403ededdadabe07d2aa` |
|
||||
| Maintainer-run environments | Windows 11/amd64 on NTFS; Ubuntu 20.04/amd64 under WSL2 on ext4; supplementary pre-beta Linux/amd64 server containers |
|
||||
| Supported Go lanes exercised | Go 1.25.12 and Go 1.26.5 |
|
||||
| Declared minimum Go | Go 1.25 |
|
||||
| Assessor | Project maintainer with AI-assisted code review; human responsibility retained |
|
||||
|
||||
The named platform runs used the exact public commit and tree above. Hostnames,
|
||||
network addresses, account names, private paths, private repository identities,
|
||||
and private commit mappings are intentionally absent from this public ledger.
|
||||
|
||||
Beta 1 necessarily changes the tree through versioning, provenance,
|
||||
documentation, or source fixes. Therefore this baseline cannot be relabeled as
|
||||
Beta 1 evidence. The required Windows/Linux campaign must pass again on the
|
||||
exact Beta 1 candidate before either tag is published. Native macOS execution
|
||||
remains pending and is provisional for the beta.
|
||||
The named Windows and WSL2 platform runs used the exact public commit and tree
|
||||
above. The isolated server-container matrix preceded the final candidate and
|
||||
is retained only as supplementary Linux evidence. Hostnames, network addresses,
|
||||
account names, private paths, private repository identities, and private commit
|
||||
mappings are intentionally absent from this public ledger. Native macOS
|
||||
execution remains pending and is provisional for the beta.
|
||||
|
||||
## Observed security self-assessment evidence
|
||||
|
||||
@@ -52,7 +49,7 @@ baseline commit.
|
||||
| URL scheme handling | ordinary/trusted URL test matrix | Pass for enumerated cases |
|
||||
| Filesystem boundaries | symlink, nested-module, VCS, ownership, stale-output tests | Pass for tested cases; see open findings |
|
||||
| Development proxy browser boundary | Host, Origin, Fetch Metadata, CSP, fragment and response tests | Pass for tested cases |
|
||||
| Platform behavior | Native Windows and executed Linux matrices; macOS cross-compilation | Windows/Linux pass for tested lanes; native macOS pending |
|
||||
| Platform behavior | Exact-candidate native Windows and executed Linux matrices; macOS cross-compilation | Windows/Linux pass for tested lanes; native macOS pending |
|
||||
|
||||
Coverage measures statements executed by tests. It is not branch completeness
|
||||
and is not evidence that the executed behavior is secure.
|
||||
@@ -61,7 +58,7 @@ and is not evidence that the executed behavior is secure.
|
||||
and reachable through its analysis. A clean result cannot detect unknown flaws,
|
||||
design errors, or vulnerabilities outside its model.
|
||||
|
||||
## Pre-beta native compatibility matrix
|
||||
## Beta 1 native compatibility matrix
|
||||
|
||||
These are maintainer-run, point-in-time results, not continuous CI and not an
|
||||
independent audit.
|
||||
@@ -70,7 +67,7 @@ independent audit.
|
||||
| --- | --- | --- | --- |
|
||||
| Windows 11/amd64, NTFS | 1.25.12, 1.26.5 | Native PowerShell verifier with race; root/runtime tests, vet, trimpath build, freshness, two generation passes, process-tree cleanup, watcher boundaries, and temporary consumer compilation | Pass. Symlink-output rejection skipped because the test account lacked symlink privilege; the read-only-directory case is POSIX-only |
|
||||
| Ubuntu 20.04/amd64 under WSL2, native ext4 checkout | 1.25.12, 1.26.5 | Race-enabled verifier; root/runtime tests, vet, build, two generation passes, ten focused filesystem cases, five focused development-process/watcher cases, and license check | Pass. This is Linux execution under WSL2, not bare-metal or Linux/arm64 evidence |
|
||||
| Linux/amd64 server containers | 1.25.12, 1.26.5 | Root/runtime tests, vet, builds, race, licensing, and deterministic generation in sequential isolated official Go containers | Pass. Container resources were capped at 1 CPU and 2 GiB; this is not Linux/arm64 evidence |
|
||||
| Linux/amd64 server containers | 1.25.12, 1.26.5 | Earlier pre-beta root/runtime tests, vet, builds, race, licensing, and deterministic generation in sequential isolated official Go containers | Pass on the earlier baseline only. Container resources were capped at 1 CPU and 2 GiB; this is supplementary evidence, not an exact Beta 1 lane or Linux/arm64 evidence |
|
||||
| macOS | — | Cross-compilation only | Native maintainer execution pending; provisional for Beta 1 |
|
||||
|
||||
The generated golden `basic.sando.go` was 1,399 bytes and had SHA-256
|
||||
@@ -144,6 +141,9 @@ go test ./internal/compiler -run '^$' \
|
||||
-fuzz '^FuzzCompileNeverPanics$' -fuzztime=20s
|
||||
go test ./internal/compiler -run '^$' \
|
||||
-fuzz '^FuzzGoDelimiterNeverPanics$' -fuzztime=20s
|
||||
|
||||
./scripts/release-check.sh --version v1.0.0-beta.1
|
||||
./scripts/verify-public-install.sh --version v1.0.0-beta.1
|
||||
```
|
||||
|
||||
The fuzz targets currently assert process robustness and result bounds. They do
|
||||
@@ -152,7 +152,7 @@ not yet prove semantic HTML safety.
|
||||
## Assessment findings and remediation status
|
||||
|
||||
The 2026-08-12 assessment identified six concrete gaps. Their status in the
|
||||
named public pre-beta baseline is recorded here:
|
||||
named public Beta 1 source is recorded here:
|
||||
|
||||
| Finding | Current remediation | Executable evidence |
|
||||
| --- | --- | --- |
|
||||
@@ -163,23 +163,23 @@ named public pre-beta baseline is recorded here:
|
||||
| Trusted-value warnings were described more broadly than their analysis supports | Policy and threat-model copy now call them best-effort lexical audit hints rather than type or taint analysis | Documentation assertion and review |
|
||||
| Public copy implied a completed systematic `html/template` differential campaign | Policy and public security copy now describe fixed adversarial cases and list systematic differential work as open | Documentation assertion and review |
|
||||
|
||||
The clean remediated assessment source passed the race-enabled repository
|
||||
verifier, sanitized-snapshot tests, both bounded fuzz-smoke targets,
|
||||
compiler/runtime known-vulnerability scans, and Windows/macOS cross-compilation
|
||||
on 2026-08-12. Separately, the exact public pre-beta commit passed the native
|
||||
Windows and executed Linux matrices recorded above. These results still do not
|
||||
become Beta 1 evidence: both sets of required checks must run on the exact
|
||||
candidate after all candidate changes. Native macOS and the other gaps below
|
||||
remain separate release decisions.
|
||||
The exact public Beta 1 source passed the race-enabled repository verifier,
|
||||
sanitized-snapshot tests, both bounded fuzz-smoke targets, compiler/runtime
|
||||
known-vulnerability scans, candidate-version provenance checks, native Windows
|
||||
and executed Linux matrices, and Windows/macOS cross-compilation on 2026-08-12.
|
||||
Signed annotated runtime and compiler tags were then published from that commit
|
||||
in that order. Fresh runtime-first installation passed through both direct Git
|
||||
resolution and the public Go proxy after normal proxy propagation. Native
|
||||
macOS and the other gaps below remain separate release decisions.
|
||||
|
||||
## Open assurance gaps
|
||||
|
||||
- the exact Beta 1 candidate Windows/Linux matrix and post-tag install checks
|
||||
must still run;
|
||||
- confidential mailbox delivery and response/recovery procedure must be tested;
|
||||
- SSH tag-signing rehearsal passed, but the candidate tags still require
|
||||
post-publication verification; prebuilt-artifact signing, checksums, SBOM,
|
||||
reproducible provenance, and key recovery remain incomplete;
|
||||
- delivery to `security@sandwichhime.com` is owner-confirmed through a
|
||||
controlled domain catch-all; encrypted reporting, documented backup, and
|
||||
recovery rehearsal remain incomplete;
|
||||
- the signed annotated Beta tags and their common peeled commit were verified;
|
||||
prebuilt-artifact signing, checksums, SBOM, reproducible provenance, and key
|
||||
recovery remain incomplete;
|
||||
- native macOS, Linux/arm64, and Windows/arm64 execution remain outstanding;
|
||||
- Windows symlink rejection was not natively exercised because the test account
|
||||
lacked symlink privilege;
|
||||
|
||||
+12
-15
@@ -31,17 +31,14 @@ repeatable install for learners and evaluators without claiming that the final
|
||||
v1 compatibility, native-platform, artifact, signing, or soak gates are
|
||||
complete.
|
||||
|
||||
### Demonstrated in the pre-beta public baseline
|
||||
### Demonstrated for Beta 1
|
||||
|
||||
Public commit `113c95c21e57227b4675c9fda015ada59cc9e9a6` (tree
|
||||
`a2aeb4dac22853cb3894e3e487b94bbeff5051e5`) passed maintainer-run Go
|
||||
Public commit `b7a84054d755e42285e50298e41e47f06a8325a5` (tree
|
||||
`be9e118e38dfebed19f60403ededdadabe07d2aa`) passed maintainer-run Go
|
||||
1.25.12 and Go 1.26.5 matrices on native Windows/amd64, Linux/amd64 under WSL2,
|
||||
and isolated Linux/amd64 server containers. The same generated golden SHA-256
|
||||
was observed across those lanes.
|
||||
|
||||
That result is a pre-beta baseline only. The exact Beta 1 candidate must rerun
|
||||
the required Windows/Linux matrix after all version, documentation, and source
|
||||
changes and before tags are created.
|
||||
with the earlier pre-beta server-container run retained only as supplementary
|
||||
Linux evidence. The same generated golden SHA-256 was observed across the exact
|
||||
Beta Windows and Linux lanes.
|
||||
|
||||
Other demonstrated controls include:
|
||||
|
||||
@@ -63,8 +60,7 @@ Other demonstrated controls include:
|
||||
- complete real-browser development-supervisor evidence;
|
||||
- deterministic prebuilt archives, checksums, SBOMs, signed binaries, and
|
||||
tested signing/recovery procedures; or
|
||||
- clean direct and public-proxy installation of the not-yet-published Beta 1
|
||||
tags.
|
||||
- native macOS installation of the published Beta 1 tags.
|
||||
|
||||
## Beta 1 publication lane
|
||||
|
||||
@@ -75,13 +71,14 @@ dependency, and its interfaces may change.
|
||||
- [x] Define beta support, security, compatibility, and macOS-provisional
|
||||
language.
|
||||
- [x] Establish the named public pre-beta Linux/Windows baseline.
|
||||
- [ ] Rerun the supported Go matrix and deterministic generation on the exact
|
||||
- [x] Rerun the supported Go matrix and deterministic generation on the exact
|
||||
Beta 1 candidate.
|
||||
- [ ] Run the candidate-version freshness, bounded fuzz, vulnerability, and
|
||||
- [x] Run the candidate-version freshness, bounded fuzz, vulnerability, and
|
||||
license gates.
|
||||
- [ ] Publish immutable `sando/v1.0.0-beta.1`, then
|
||||
- [x] Publish immutable `sando/v1.0.0-beta.1`, then
|
||||
`v1.0.0-beta.1`, from the same reviewed public commit.
|
||||
- [ ] Verify clean direct and public-proxy installs and record the result.
|
||||
- [x] Verify clean runtime-first direct and public-proxy installs and record the
|
||||
result.
|
||||
- [ ] Add native macOS maintainer evidence before RC; community reports inform
|
||||
that work but do not replace maintainer responsibility.
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// SourceInput supplies one in-memory .sando document for editor analysis.
|
||||
type SourceInput struct {
|
||||
Path string
|
||||
Source []byte
|
||||
}
|
||||
|
||||
// AnalysisImport describes one import already present in a .sando header.
|
||||
type AnalysisImport struct {
|
||||
Alias string `json:"alias,omitempty"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// AnalysisRegionKind identifies an author-visible template region.
|
||||
type AnalysisRegionKind string
|
||||
|
||||
const (
|
||||
AnalysisStatement AnalysisRegionKind = "statement"
|
||||
AnalysisExpression AnalysisRegionKind = "expression"
|
||||
AnalysisComponent AnalysisRegionKind = "component"
|
||||
AnalysisComment AnalysisRegionKind = "comment"
|
||||
)
|
||||
|
||||
// AnalysisRegion describes a Hime-san tag body using zero-based byte offsets.
|
||||
// Line and Column retain the compiler's one-based byte-coordinate convention;
|
||||
// protocol adapters convert them to UTF-16 where required.
|
||||
type AnalysisRegion struct {
|
||||
Kind AnalysisRegionKind `json:"kind"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Context Context `json:"context,omitempty"`
|
||||
Offset int `json:"offset"`
|
||||
Length int `json:"length"`
|
||||
Line int `json:"line"`
|
||||
Column int `json:"column"`
|
||||
}
|
||||
|
||||
// DocumentAnalysis is the compiler-owned semantic description consumed by
|
||||
// read-only tools such as the language server.
|
||||
type DocumentAnalysis struct {
|
||||
Path string `json:"path"`
|
||||
Package string `json:"package,omitempty"`
|
||||
Component string `json:"component,omitempty"`
|
||||
TypeParams string `json:"type_params,omitempty"`
|
||||
Params string `json:"params,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
ComponentOffset int `json:"component_offset,omitempty"`
|
||||
ComponentLine int `json:"component_line,omitempty"`
|
||||
ComponentColumn int `json:"component_column,omitempty"`
|
||||
Imports []AnalysisImport `json:"imports,omitempty"`
|
||||
Regions []AnalysisRegion `json:"regions,omitempty"`
|
||||
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
// AnalyzeSources applies the normal parser, HTML-context analyzer, trust
|
||||
// audit, backend validation, duplicate-component checks, and statically
|
||||
// knowable cycle checks to an in-memory source set. It performs no I/O.
|
||||
func AnalyzeSources(ctx context.Context, inputs []SourceInput) []DocumentAnalysis {
|
||||
ordered := append([]SourceInput(nil), inputs...)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return filepath.Clean(ordered[i].Path) < filepath.Clean(ordered[j].Path)
|
||||
})
|
||||
analyses := make([]DocumentAnalysis, 0, len(ordered))
|
||||
compiled := make([]CompiledFile, 0, len(ordered))
|
||||
for _, input := range ordered {
|
||||
if err := ctx.Err(); err != nil {
|
||||
analyses = append(analyses, DocumentAnalysis{
|
||||
Path: filepath.Clean(input.Path),
|
||||
Diagnostics: []Diagnostic{diagnostic(input.Path, sourcePosition{Line: 1, Column: 1}, "HIM2001", "operation canceled: "+err.Error())},
|
||||
})
|
||||
continue
|
||||
}
|
||||
analysis, output := analyzeSource(input.Path, input.Source)
|
||||
analyses = append(analyses, analysis)
|
||||
if output.Code != nil {
|
||||
compiled = append(compiled, output)
|
||||
}
|
||||
}
|
||||
byPath := make(map[string][]Diagnostic)
|
||||
for _, item := range detectComponentCycles(compiled) {
|
||||
path := filepath.Clean(item.Path)
|
||||
byPath[path] = append(byPath[path], item)
|
||||
}
|
||||
for index := range analyses {
|
||||
path := filepath.Clean(analyses[index].Path)
|
||||
analyses[index].Diagnostics = append(analyses[index].Diagnostics, byPath[path]...)
|
||||
sortDiagnostics(analyses[index].Diagnostics)
|
||||
}
|
||||
return analyses
|
||||
}
|
||||
|
||||
func analyzeSource(path string, source []byte) (DocumentAnalysis, CompiledFile) {
|
||||
cleanPath := filepath.Clean(path)
|
||||
analysis := DocumentAnalysis{Path: cleanPath}
|
||||
file, diagnostics := parseSource(cleanPath, source)
|
||||
if file == nil {
|
||||
sortDiagnostics(diagnostics)
|
||||
analysis.Diagnostics = diagnostics
|
||||
return analysis, CompiledFile{}
|
||||
}
|
||||
analysis.Package = file.Package
|
||||
analysis.Component = file.Name
|
||||
analysis.TypeParams = file.TypeParams
|
||||
analysis.Params = file.Params
|
||||
analysis.Signature = fmt.Sprintf("func %s%s%s", file.Name, file.TypeParams, file.Params)
|
||||
analysis.ComponentOffset = file.FunctionPos.Offset
|
||||
analysis.ComponentLine = file.FunctionPos.Line
|
||||
analysis.ComponentColumn = file.FunctionPos.Column
|
||||
for _, imported := range file.Imports {
|
||||
analysis.Imports = append(analysis.Imports, AnalysisImport{Alias: imported.Alias, Path: imported.Path})
|
||||
}
|
||||
diagnostics = append(diagnostics, analyzeContexts(file)...)
|
||||
diagnostics = append(diagnostics, auditTrustCalls(file)...)
|
||||
for _, node := range file.Nodes {
|
||||
kind := AnalysisRegionKind("")
|
||||
switch node.Kind {
|
||||
case nodeStatement:
|
||||
kind = AnalysisStatement
|
||||
case nodeExpression:
|
||||
kind = AnalysisExpression
|
||||
case nodeComponent:
|
||||
kind = AnalysisComponent
|
||||
case nodeComment:
|
||||
kind = AnalysisComment
|
||||
default:
|
||||
continue
|
||||
}
|
||||
analysis.Regions = append(analysis.Regions, AnalysisRegion{
|
||||
Kind: kind, Text: node.Text, Context: node.Context,
|
||||
Offset: node.Pos.Offset, Length: len(node.Text),
|
||||
Line: node.Pos.Line, Column: node.Pos.Column,
|
||||
})
|
||||
}
|
||||
if hasErrors(diagnostics) {
|
||||
sortDiagnostics(diagnostics)
|
||||
analysis.Diagnostics = diagnostics
|
||||
return analysis, CompiledFile{}
|
||||
}
|
||||
code, backendDiagnostics := generateGo(file)
|
||||
diagnostics = append(diagnostics, backendDiagnostics...)
|
||||
sortDiagnostics(diagnostics)
|
||||
analysis.Diagnostics = diagnostics
|
||||
if hasErrors(diagnostics) {
|
||||
return analysis, CompiledFile{}
|
||||
}
|
||||
return analysis, CompiledFile{
|
||||
SourcePath: cleanPath,
|
||||
OutputPath: cleanPath + ".go",
|
||||
Package: file.Package,
|
||||
Component: file.Name,
|
||||
Code: code,
|
||||
source: file,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package compiler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnalyzeSourcesUsesCompilerSemanticsWithoutIO(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
firstPath := filepath.Join(directory, "first.sando")
|
||||
secondPath := filepath.Join(directory, "second.sando")
|
||||
first := []byte("<?sando go\npackage views\nfunc First(name string)\n?>\n<div><?= name ?><?~ Second() ?></div>\n")
|
||||
second := []byte("<?sando go\npackage views\nfunc Second()\n?>\n<section><?~ First(\"again\") ?></section>\n")
|
||||
analyses := AnalyzeSources(context.Background(), []SourceInput{{Path: firstPath, Source: first}, {Path: secondPath, Source: second}})
|
||||
if len(analyses) != 2 {
|
||||
t.Fatalf("analysis count = %d, want 2", len(analyses))
|
||||
}
|
||||
for _, analysis := range analyses {
|
||||
if analysis.Component == "" || analysis.Signature == "" || analysis.ComponentLine < 1 {
|
||||
t.Fatalf("missing component metadata: %#v", analysis)
|
||||
}
|
||||
if !hasDiagnosticCode(analysis.Diagnostics, "HIM1501") {
|
||||
t.Fatalf("cycle diagnostic missing for %s: %#v", analysis.Component, analysis.Diagnostics)
|
||||
}
|
||||
if _, err := os.Stat(analysis.Path + ".go"); !os.IsNotExist(err) {
|
||||
t.Fatalf("analysis wrote generated output: %v", err)
|
||||
}
|
||||
}
|
||||
if analyses[0].Regions[0].Context != ContextHTMLText {
|
||||
t.Fatalf("expression context = %q, want %q", analyses[0].Regions[0].Context, ContextHTMLText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeSourcesDuplicateAndMalformedDocuments(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
duplicate := "<?sando go\npackage views\nfunc Card()\n?>\n<p>card</p>\n"
|
||||
malformed := "<?sando go\npackage views\nfunc Broken()\n?>\n<div>\x00"
|
||||
analyses := AnalyzeSources(context.Background(), []SourceInput{
|
||||
{Path: filepath.Join(directory, "a.sando"), Source: []byte(duplicate)},
|
||||
{Path: filepath.Join(directory, "b.sando"), Source: []byte(duplicate)},
|
||||
{Path: filepath.Join(directory, "broken.sando"), Source: []byte(malformed)},
|
||||
})
|
||||
if !hasDiagnosticCode(analyses[0].Diagnostics, "HIM1500") || !hasDiagnosticCode(analyses[1].Diagnostics, "HIM1500") {
|
||||
t.Fatalf("duplicate diagnostics missing: %#v", analyses)
|
||||
}
|
||||
if !hasDiagnosticCode(analyses[2].Diagnostics, "HIM1002") {
|
||||
t.Fatalf("NUL diagnostic missing: %#v", analyses[2].Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverSourcesOmitsGeneratedFreshnessButKeepsBoundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/root\n\ngo 1.25\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "orphan.sando.go"), []byte(generatedPrefix+"\npackage root\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nested := filepath.Join(root, "nested")
|
||||
if err := os.Mkdir(nested, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nested, "go.mod"), []byte("module example.test/nested\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nested, "hidden.sando"), []byte("ignored"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths, diagnostics := DiscoverSources(context.Background(), []string{root})
|
||||
if len(paths) != 0 {
|
||||
t.Fatalf("discovered nested source: %v", paths)
|
||||
}
|
||||
for _, item := range diagnostics {
|
||||
if item.Code == "HIM2014" {
|
||||
t.Fatalf("editor discovery reported generated freshness: %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasDiagnosticCode(diagnostics []Diagnostic, code string) bool {
|
||||
for _, item := range diagnostics {
|
||||
if item.Code == code || strings.HasPrefix(item.Code, code) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -23,6 +23,17 @@ var excludedDirectories = map[string]bool{
|
||||
}
|
||||
|
||||
func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
|
||||
return discoverWithOptions(ctx, paths, true)
|
||||
}
|
||||
|
||||
// DiscoverSources finds .sando sources using the same filesystem, symlink,
|
||||
// nested-module, VCS, and vendor boundaries as Generate and Check. It omits
|
||||
// generated-output inspection because editor analysis does not own freshness.
|
||||
func DiscoverSources(ctx context.Context, paths []string) ([]string, []Diagnostic) {
|
||||
return discoverWithOptions(ctx, paths, false)
|
||||
}
|
||||
|
||||
func discoverWithOptions(ctx context.Context, paths []string, inspectGenerated bool) ([]string, []Diagnostic) {
|
||||
if len(paths) == 0 {
|
||||
paths = []string{"."}
|
||||
}
|
||||
@@ -121,6 +132,9 @@ func discover(ctx context.Context, paths []string) ([]string, []Diagnostic) {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(entry.Name(), ".sando.go") {
|
||||
if !inspectGenerated {
|
||||
return nil
|
||||
}
|
||||
entryInfo, statErr := entry.Info()
|
||||
if statErr != nil {
|
||||
diagnostics = append(diagnostics, diagnostic(path, sourcePosition{Line: 1, Column: 1}, "HIM2013", "cannot inspect possible generated output: "+statErr.Error()))
|
||||
|
||||
@@ -69,6 +69,7 @@ type sourceFile struct {
|
||||
Nodes []rendererNode
|
||||
Source []byte
|
||||
HeaderEnd int
|
||||
FunctionPos sourcePosition
|
||||
AST *ast.File
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ func parseSource(path string, source []byte) (*sourceFile, []Diagnostic) {
|
||||
Imports: parsedHeader.Imports,
|
||||
Source: source,
|
||||
HeaderEnd: headerClose + 2,
|
||||
FunctionPos: parsedHeader.FunctionPos,
|
||||
AST: parsedHeader.AST,
|
||||
}
|
||||
|
||||
@@ -144,6 +145,7 @@ type parsedHeader struct {
|
||||
TypeParams string
|
||||
Params string
|
||||
Imports []sourceImport
|
||||
FunctionPos sourcePosition
|
||||
AST *ast.File
|
||||
}
|
||||
|
||||
@@ -259,6 +261,7 @@ func parseHeader(path string, declarations []byte, sourceOffset int, table posit
|
||||
TypeParams: typeParams,
|
||||
Params: params,
|
||||
Imports: imports,
|
||||
FunctionPos: table.at(sourceOffset + functionPosition.Offset),
|
||||
AST: parsed,
|
||||
}, diagnostics
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
|
||||
t.Skip("integration test builds temporary Go applications")
|
||||
}
|
||||
root := t.TempDir()
|
||||
disableParentVCSStamping(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/himesan-dev-test\n\ngo 1.25\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -41,7 +42,10 @@ func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) {
|
||||
generations.Add(1)
|
||||
return nil
|
||||
},
|
||||
OnEvent: func(event Event) { events <- event },
|
||||
OnEvent: func(event Event) {
|
||||
t.Logf("supervisor event: type=%s phase=%s message=%s", event.Type, event.Phase, event.Message)
|
||||
events <- event
|
||||
},
|
||||
CacheDir: filepath.Join(t.TempDir(), "cache"),
|
||||
PollInterval: 25 * time.Millisecond,
|
||||
Debounce: 25 * time.Millisecond,
|
||||
@@ -101,6 +105,7 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
|
||||
t.Skip("integration test builds a temporary Go application")
|
||||
}
|
||||
root := t.TempDir()
|
||||
disableParentVCSStamping(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/himesan-dev-exit-test\n\ngo 1.25\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -115,7 +120,10 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
|
||||
RootDir: root,
|
||||
Config: cfg,
|
||||
Generate: func(context.Context) error { return nil },
|
||||
OnEvent: func(event Event) { events <- event },
|
||||
OnEvent: func(event Event) {
|
||||
t.Logf("supervisor event: type=%s phase=%s message=%s", event.Type, event.Phase, event.Message)
|
||||
events <- event
|
||||
},
|
||||
CacheDir: filepath.Join(t.TempDir(), "cache"),
|
||||
PollInterval: 30 * time.Second,
|
||||
Debounce: 25 * time.Millisecond,
|
||||
@@ -163,6 +171,15 @@ func TestSupervisorClearsTargetWhenCurrentApplicationExits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func disableParentVCSStamping(t *testing.T) {
|
||||
t.Helper()
|
||||
// A temporary standalone module can live under a parent directory that is
|
||||
// itself a VCS checkout (including hardened test sandboxes). Its candidate
|
||||
// must not inherit or depend on that unrelated repository's status.
|
||||
flags := strings.TrimSpace(os.Getenv("GOFLAGS") + " -buildvcs=false")
|
||||
t.Setenv("GOFLAGS", flags)
|
||||
}
|
||||
|
||||
func TestGenerationFailureDoesNotMoveProxyTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
upstream := http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/compiler"
|
||||
)
|
||||
|
||||
type completionItem struct {
|
||||
Label string `json:"label"`
|
||||
Kind int `json:"kind,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Documentation any `json:"documentation,omitempty"`
|
||||
InsertText string `json:"insertText,omitempty"`
|
||||
InsertTextFormat int `json:"insertTextFormat,omitempty"`
|
||||
SortText string `json:"sortText,omitempty"`
|
||||
}
|
||||
|
||||
type markupContent struct {
|
||||
Kind string `json:"kind"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type hoverResult struct {
|
||||
Contents markupContent `json:"contents"`
|
||||
Range *Range `json:"range,omitempty"`
|
||||
}
|
||||
|
||||
type documentSymbol struct {
|
||||
Name string `json:"name"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Kind int `json:"kind"`
|
||||
Range Range `json:"range"`
|
||||
SelectionRange Range `json:"selectionRange"`
|
||||
Children []documentSymbol `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func (server *Server) completions(request textDocumentPositionParams) any {
|
||||
document, analysis, ok := server.snapshotDocument(request.TextDocument.URI)
|
||||
if !ok {
|
||||
return []completionItem{}
|
||||
}
|
||||
offset, ok := positionToOffset(document.Text, request.Position)
|
||||
if !ok {
|
||||
return []completionItem{}
|
||||
}
|
||||
open := bytes.LastIndex(document.Text[:offset], []byte("<?"))
|
||||
close := bytes.LastIndex(document.Text[:offset], []byte("?>"))
|
||||
if open >= 0 && open > close && bytes.HasPrefix(document.Text[open:offset], []byte("<?~")) {
|
||||
server.mu.RLock()
|
||||
snapshot := server.snapshot
|
||||
server.mu.RUnlock()
|
||||
prefix := strings.TrimSpace(string(document.Text[open+3 : offset]))
|
||||
items := make([]completionItem, 0)
|
||||
for _, target := range snapshot.componentsFor(document.Path, analysis) {
|
||||
label := target.Label()
|
||||
if prefix != "" && !strings.HasPrefix(label, prefix) {
|
||||
continue
|
||||
}
|
||||
items = append(items, completionItem{
|
||||
Label: label, Kind: 3, Detail: target.Signature,
|
||||
Documentation: markupContent{Kind: "markdown", Value: "Typed `.sando` component. Hime-san emits an ordinary Go constructor."},
|
||||
InsertText: label, InsertTextFormat: 1, SortText: "1-" + label,
|
||||
})
|
||||
}
|
||||
return struct {
|
||||
IsIncomplete bool `json:"isIncomplete"`
|
||||
Items []completionItem `json:"items"`
|
||||
}{Items: items}
|
||||
}
|
||||
return struct {
|
||||
IsIncomplete bool `json:"isIncomplete"`
|
||||
Items []completionItem `json:"items"`
|
||||
}{Items: tagCompletions()}
|
||||
}
|
||||
|
||||
func tagCompletions() []completionItem {
|
||||
return []completionItem{
|
||||
{Label: "<?sando go", Kind: 15, Detail: "component file header", InsertText: "<?sando go\npackage ${1:views}\nfunc ${2:Component}(${3:})\n?>", InsertTextFormat: 2, SortText: "0-header"},
|
||||
{Label: "<? … ?>", Kind: 15, Detail: "Go statement", InsertText: "<? ${1:if condition {} } ?>", InsertTextFormat: 2, SortText: "0-statement"},
|
||||
{Label: "<?= … ?>", Kind: 15, Detail: "contextually escaped expression", InsertText: "<?= ${1:value} ?>", InsertTextFormat: 2, SortText: "0-expression"},
|
||||
{Label: "<?~ … ?>", Kind: 15, Detail: "typed component composition", InsertText: "<?~ ${1:Component()} ?>", InsertTextFormat: 2, SortText: "0-component"},
|
||||
{Label: "<?# … ?>", Kind: 15, Detail: "Hime-san template comment", InsertText: "<?# ${1:comment} ?>", InsertTextFormat: 2, SortText: "0-comment"},
|
||||
}
|
||||
}
|
||||
|
||||
func (server *Server) hover(request textDocumentPositionParams) any {
|
||||
document, analysis, ok := server.snapshotDocument(request.TextDocument.URI)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
offset, ok := positionToOffset(document.Text, request.Position)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, region := range analysis.Regions {
|
||||
if offset < region.Offset || offset > region.Offset+region.Length {
|
||||
continue
|
||||
}
|
||||
if region.Kind == compiler.AnalysisComponent {
|
||||
qualifier, name := referenceAt(region.Text, offset-region.Offset)
|
||||
if name != "" {
|
||||
if target, found := server.resolveComponent(document.Path, analysis, qualifier, name); found {
|
||||
value := "```go\n" + target.Signature + "\n```\n\nTyped component composition. Handwritten `sando.Component` values are trusted output capabilities."
|
||||
return hoverResult{Contents: markupContent{Kind: "markdown", Value: value}}
|
||||
}
|
||||
}
|
||||
}
|
||||
value := fmt.Sprintf("**Hime-san %s region**\n\nOutput context: `%s`.", region.Kind, region.Context)
|
||||
if region.Context == compiler.ContextJS || region.Context == compiler.ContextCSS || strings.Contains(region.Text, "Trust") {
|
||||
value += "\n\nTrusted output is an explicit security capability; audit its provenance and parser-state effects."
|
||||
}
|
||||
return hoverResult{Contents: markupContent{Kind: "markdown", Value: value}}
|
||||
}
|
||||
if marker, start, end := enclosingTag(document.Text, offset); marker != "" {
|
||||
if text := tagDocumentation(marker); text != "" {
|
||||
rangeValue := Range{Start: offsetToPosition(document.Text, start), End: offsetToPosition(document.Text, end)}
|
||||
return hoverResult{Contents: markupContent{Kind: "markdown", Value: text}, Range: &rangeValue}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tagDocumentation(marker string) string {
|
||||
switch marker {
|
||||
case "<?sando":
|
||||
return "**`<?sando go … ?>`** declares the Go package, imports, and one typed component signature. It must be the first non-whitespace content."
|
||||
case "<?~":
|
||||
return "**`<?~ … ?>`** composes a typed `sando.Component` at an HTML content boundary. It is not template inheritance."
|
||||
case "<?=":
|
||||
return "**`<?= … ?>`** renders an expression through the helper selected by Hime-san's inferred HTML output context."
|
||||
case "<?#":
|
||||
return "**`<?# … ?>`** is a Hime-san comment. It emits no bytes and cannot change HTML parser state."
|
||||
case "<?":
|
||||
return "**`<? … ?>`** contains Go statements and is valid only at an HTML content boundary."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func enclosingTag(text []byte, offset int) (string, int, int) {
|
||||
if offset < 0 || offset > len(text) {
|
||||
return "", 0, 0
|
||||
}
|
||||
open := bytes.LastIndex(text[:offset], []byte("<?"))
|
||||
if open < 0 {
|
||||
return "", 0, 0
|
||||
}
|
||||
closeRelative := bytes.Index(text[open:], []byte("?>"))
|
||||
if closeRelative < 0 || open+closeRelative+2 < offset {
|
||||
return "", 0, 0
|
||||
}
|
||||
end := open + closeRelative + 2
|
||||
marker := "<?"
|
||||
for _, candidate := range []string{"<?sando", "<?~", "<?=", "<?#"} {
|
||||
if bytes.HasPrefix(text[open:end], []byte(candidate)) {
|
||||
marker = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
return marker, open, end
|
||||
}
|
||||
|
||||
func (server *Server) definition(request textDocumentPositionParams) any {
|
||||
document, analysis, ok := server.snapshotDocument(request.TextDocument.URI)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
offset, ok := positionToOffset(document.Text, request.Position)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, region := range analysis.Regions {
|
||||
if region.Kind != compiler.AnalysisComponent || offset < region.Offset || offset > region.Offset+region.Length {
|
||||
continue
|
||||
}
|
||||
qualifier, name := referenceAt(region.Text, offset-region.Offset)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
target, found := server.resolveComponent(document.Path, analysis, qualifier, name)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
server.mu.RLock()
|
||||
targetDocument, exists := server.snapshot.documents[target.Path]
|
||||
server.mu.RUnlock()
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
start := target.Analysis.ComponentOffset
|
||||
end := start + len(target.Analysis.Signature)
|
||||
return Location{URI: targetDocument.URI, Range: Range{Start: offsetToPosition(targetDocument.Text, start), End: offsetToPosition(targetDocument.Text, end)}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (server *Server) resolveComponent(path string, analysis compiler.DocumentAnalysis, qualifier, name string) (componentTarget, bool) {
|
||||
server.mu.RLock()
|
||||
snapshot := server.snapshot
|
||||
server.mu.RUnlock()
|
||||
for _, target := range snapshot.componentsFor(path, analysis) {
|
||||
if target.Qualifier == qualifier && target.Name == name {
|
||||
return target, true
|
||||
}
|
||||
}
|
||||
return componentTarget{}, false
|
||||
}
|
||||
|
||||
func referenceAt(expression string, cursor int) (string, string) {
|
||||
if cursor < 0 {
|
||||
cursor = 0
|
||||
}
|
||||
if cursor > len(expression) {
|
||||
cursor = len(expression)
|
||||
}
|
||||
if cursor == len(expression) && cursor > 0 {
|
||||
cursor--
|
||||
}
|
||||
for cursor > 0 && cursor < len(expression) && !identifierByte(expression[cursor]) && expression[cursor] != '.' {
|
||||
cursor--
|
||||
}
|
||||
start := cursor
|
||||
for start > 0 && (identifierByte(expression[start-1]) || expression[start-1] == '.') {
|
||||
start--
|
||||
}
|
||||
end := cursor
|
||||
for end < len(expression) && (identifierByte(expression[end]) || expression[end] == '.') {
|
||||
end++
|
||||
}
|
||||
reference := strings.Trim(expression[start:end], ".")
|
||||
parts := strings.Split(reference, ".")
|
||||
if len(parts) == 1 && validIdentifier(parts[0]) {
|
||||
return "", parts[0]
|
||||
}
|
||||
if len(parts) == 2 && validIdentifier(parts[0]) && validIdentifier(parts[1]) {
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func identifierByte(value byte) bool {
|
||||
return value == '_' || value >= 'a' && value <= 'z' || value >= 'A' && value <= 'Z' || value >= '0' && value <= '9'
|
||||
}
|
||||
|
||||
func validIdentifier(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for index, r := range value {
|
||||
if index == 0 && !(r == '_' || unicode.IsLetter(r)) {
|
||||
return false
|
||||
}
|
||||
if index != 0 && !(r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (server *Server) documentSymbols(uri string) []documentSymbol {
|
||||
document, analysis, ok := server.snapshotDocument(uri)
|
||||
if !ok || analysis.Component == "" {
|
||||
return []documentSymbol{}
|
||||
}
|
||||
documentRange := Range{Start: Position{}, End: offsetToPosition(document.Text, len(document.Text))}
|
||||
selectionStart := analysis.ComponentOffset
|
||||
selectionEnd := selectionStart + len(analysis.Component)
|
||||
children := make([]documentSymbol, 0, len(analysis.Regions))
|
||||
for index, region := range analysis.Regions {
|
||||
end := region.Offset + region.Length
|
||||
name := fmt.Sprintf("%s %d", region.Kind, index+1)
|
||||
if region.Kind == compiler.AnalysisComponent {
|
||||
_, componentName := referenceAt(region.Text, 0)
|
||||
if componentName != "" {
|
||||
name = "component " + componentName
|
||||
}
|
||||
}
|
||||
rangeValue := Range{Start: offsetToPosition(document.Text, region.Offset), End: offsetToPosition(document.Text, end)}
|
||||
children = append(children, documentSymbol{Name: name, Detail: string(region.Context), Kind: 13, Range: rangeValue, SelectionRange: rangeValue})
|
||||
}
|
||||
return []documentSymbol{{
|
||||
Name: analysis.Component, Detail: analysis.Signature, Kind: 12,
|
||||
Range: documentRange,
|
||||
SelectionRange: Range{Start: offsetToPosition(document.Text, selectionStart), End: offsetToPosition(document.Text, selectionEnd)},
|
||||
Children: children,
|
||||
}}
|
||||
}
|
||||
|
||||
func runeEnd(text []byte, offset int) int {
|
||||
if offset >= len(text) {
|
||||
return len(text)
|
||||
}
|
||||
_, size := utf8.DecodeRune(text[offset:])
|
||||
if size < 1 {
|
||||
size = 1
|
||||
}
|
||||
return offset + size
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
maxMessageBytes = 16 << 20
|
||||
maxHeaderBytes = 64 << 10
|
||||
maxHeaderLines = 64
|
||||
)
|
||||
|
||||
type rpcMessage struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID json.RawMessage `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Params json.RawMessage `json:"params,omitempty"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func validRequestID(id json.RawMessage) bool {
|
||||
if len(id) == 0 {
|
||||
return true
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(id, &value); err != nil {
|
||||
return false
|
||||
}
|
||||
switch value := value.(type) {
|
||||
case string:
|
||||
return true
|
||||
case float64:
|
||||
return value == math.Trunc(value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
errParse = -32700
|
||||
errInvalidRequest = -32600
|
||||
errMethodNotFound = -32601
|
||||
errInvalidParams = -32602
|
||||
errInternal = -32603
|
||||
errRequestCancelled = -32800
|
||||
)
|
||||
|
||||
type frameReader struct{ reader *bufio.Reader }
|
||||
|
||||
func newFrameReader(input io.Reader) *frameReader {
|
||||
return &frameReader{reader: bufio.NewReaderSize(input, 64<<10)}
|
||||
}
|
||||
|
||||
func (reader *frameReader) read() ([]byte, error) {
|
||||
contentLength := -1
|
||||
headerBytes := 0
|
||||
headerLines := 0
|
||||
for {
|
||||
line, err := reader.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
headerBytes += len(line)
|
||||
headerLines++
|
||||
if headerBytes > maxHeaderBytes || headerLines > maxHeaderLines {
|
||||
return nil, errors.New("LSP headers exceed configured limits")
|
||||
}
|
||||
if len(line) > 8<<10 {
|
||||
return nil, errors.New("LSP header line exceeds 8 KiB")
|
||||
}
|
||||
line = strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
name, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
return nil, errors.New("malformed LSP header")
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(name), "Content-Length") {
|
||||
if contentLength >= 0 {
|
||||
return nil, errors.New("duplicate Content-Length header")
|
||||
}
|
||||
parsed, parseErr := strconv.Atoi(strings.TrimSpace(value))
|
||||
if parseErr != nil || parsed < 0 || parsed > maxMessageBytes {
|
||||
return nil, errors.New("invalid or excessive Content-Length")
|
||||
}
|
||||
contentLength = parsed
|
||||
}
|
||||
}
|
||||
if contentLength < 0 {
|
||||
return nil, errors.New("missing Content-Length header")
|
||||
}
|
||||
payload := make([]byte, contentLength)
|
||||
if _, err := io.ReadFull(reader.reader, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
type frameWriter struct {
|
||||
mu sync.Mutex
|
||||
output io.Writer
|
||||
}
|
||||
|
||||
func (writer *frameWriter) write(message rpcMessage) error {
|
||||
payload, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var frame bytes.Buffer
|
||||
fmt.Fprintf(&frame, "Content-Length: %d\r\n\r\n", len(payload))
|
||||
frame.Write(payload)
|
||||
writer.mu.Lock()
|
||||
defer writer.mu.Unlock()
|
||||
written, err := writer.output.Write(frame.Bytes())
|
||||
if err == nil && written != frame.Len() {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type Position struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
}
|
||||
|
||||
type Range struct {
|
||||
Start Position `json:"start"`
|
||||
End Position `json:"end"`
|
||||
}
|
||||
|
||||
type Location struct {
|
||||
URI string `json:"uri"`
|
||||
Range Range `json:"range"`
|
||||
}
|
||||
|
||||
type lspDiagnostic struct {
|
||||
Range Range `json:"range"`
|
||||
Severity int `json:"severity,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type textDocumentIdentifier struct {
|
||||
URI string `json:"uri"`
|
||||
}
|
||||
|
||||
type versionedTextDocumentIdentifier struct {
|
||||
URI string `json:"uri"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
|
||||
type textDocumentPositionParams struct {
|
||||
TextDocument textDocumentIdentifier `json:"textDocument"`
|
||||
Position Position `json:"position"`
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type shortWriter struct{}
|
||||
|
||||
func (shortWriter) Write(value []byte) (int, error) {
|
||||
if len(value) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return len(value) - 1, nil
|
||||
}
|
||||
|
||||
func TestProtocolFramingRoundTripAndLimits(t *testing.T) {
|
||||
t.Parallel()
|
||||
var output bytes.Buffer
|
||||
writer := &frameWriter{output: &output}
|
||||
if err := writer.write(rpcMessage{JSONRPC: "2.0", ID: json.RawMessage("1"), Result: map[string]bool{"ok": true}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err := newFrameReader(&output).read()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var decoded rpcMessage
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(decoded.ID) != "1" {
|
||||
t.Fatalf("response ID = %s", decoded.ID)
|
||||
}
|
||||
|
||||
invalid := "Content-Length: 999999999\r\n\r\n"
|
||||
if _, err := newFrameReader(strings.NewReader(invalid)).read(); err == nil {
|
||||
t.Fatal("excessive frame length was accepted")
|
||||
}
|
||||
duplicate := "Content-Length: 2\r\nContent-Length: 2\r\n\r\n{}"
|
||||
if _, err := newFrameReader(strings.NewReader(duplicate)).read(); err == nil {
|
||||
t.Fatal("duplicate Content-Length was accepted")
|
||||
}
|
||||
var excessive bytes.Buffer
|
||||
for range maxHeaderLines + 1 {
|
||||
excessive.WriteString("X-Test: value\r\n")
|
||||
}
|
||||
excessive.WriteString("Content-Length: 2\r\n\r\n{}")
|
||||
if _, err := newFrameReader(&excessive).read(); err == nil {
|
||||
t.Fatal("excessive header count was accepted")
|
||||
}
|
||||
if err := (&frameWriter{output: shortWriter{}}).write(rpcMessage{JSONRPC: "2.0", ID: json.RawMessage("1"), Result: true}); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("short writer error = %v, want io.ErrShortWrite", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedJSONProducesProtocolErrorAndContinues(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := bytes.NewBuffer(nil)
|
||||
input.WriteString("Content-Length: 1\r\n\r\n{")
|
||||
exit, err := json.Marshal(rpcMessage{JSONRPC: "2.0", Method: "exit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Fprintf(input, "Content-Length: %d\r\n\r\n", len(exit))
|
||||
input.Write(exit)
|
||||
var output bytes.Buffer
|
||||
if err := Run(context.Background(), Options{Input: input, Output: &output}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload, err := newFrameReader(&output).read()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var response rpcMessage
|
||||
if err := json.Unmarshal(payload, &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Error == nil || response.Error.Code != errParse {
|
||||
t.Fatalf("parse error response = %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, id := range []string{`1`, `-2`, `"request"`} {
|
||||
if !validRequestID(json.RawMessage(id)) {
|
||||
t.Errorf("valid request ID rejected: %s", id)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{`null`, `true`, `1.5`, `{}`, `[]`, `not-json`} {
|
||||
if validRequestID(json.RawMessage(id)) {
|
||||
t.Errorf("invalid request ID accepted: %s", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUTF16PositionsWithUnicodeAndCRLF(t *testing.T) {
|
||||
t.Parallel()
|
||||
text := []byte("a😀b\r\n雪c\n")
|
||||
tests := []struct {
|
||||
offset int
|
||||
position Position
|
||||
}{
|
||||
{offset: 0, position: Position{Line: 0, Character: 0}},
|
||||
{offset: 1, position: Position{Line: 0, Character: 1}},
|
||||
{offset: 5, position: Position{Line: 0, Character: 3}},
|
||||
{offset: 8, position: Position{Line: 1, Character: 0}},
|
||||
{offset: 11, position: Position{Line: 1, Character: 1}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := offsetToPosition(text, test.offset); got != test.position {
|
||||
t.Errorf("offsetToPosition(%d) = %#v, want %#v", test.offset, got, test.position)
|
||||
}
|
||||
if got, ok := positionToOffset(text, test.position); !ok || got != test.offset {
|
||||
t.Errorf("positionToOffset(%#v) = %d, %v; want %d, true", test.position, got, ok, test.offset)
|
||||
}
|
||||
}
|
||||
if _, ok := positionToOffset(text, Position{Line: 0, Character: 2}); ok {
|
||||
t.Fatal("position inside UTF-16 surrogate pair was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzFrameReaderNeverPanics(f *testing.F) {
|
||||
f.Add([]byte("Content-Length: 2\r\n\r\n{}"))
|
||||
f.Add([]byte("Content-Length: nope\r\n\r\n"))
|
||||
f.Fuzz(func(t *testing.T, data []byte) {
|
||||
if len(data) > 64<<10 {
|
||||
t.Skip()
|
||||
}
|
||||
_, _ = newFrameReader(bytes.NewReader(data)).read()
|
||||
})
|
||||
}
|
||||
|
||||
func FuzzDocumentPositionNeverPanics(f *testing.F) {
|
||||
f.Add("hello 😀\r\nworld", 0, 7)
|
||||
f.Add("雪", 0, 1)
|
||||
f.Fuzz(func(t *testing.T, text string, line, character int) {
|
||||
if len(text) > 64<<10 || line < -10000 || line > 10000 || character < -10000 || character > 100000 {
|
||||
t.Skip()
|
||||
}
|
||||
offset, ok := positionToOffset([]byte(text), Position{Line: line, Character: character})
|
||||
if ok {
|
||||
_ = offsetToPosition([]byte(text), offset)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLanguageServerSourceHasNoExecutionNetworkOrWriteCapability(t *testing.T) {
|
||||
t.Parallel()
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forbiddenImports := map[string]bool{
|
||||
"net": true, "net/http": true, "net/rpc": true,
|
||||
"os/exec": true, "syscall": true,
|
||||
}
|
||||
forbiddenOSCalls := map[string]bool{
|
||||
"Create": true, "CreateTemp": true, "Mkdir": true, "MkdirAll": true,
|
||||
"OpenFile": true, "Remove": true, "RemoveAll": true, "Rename": true,
|
||||
"WriteFile": true, "Chmod": true, "Chown": true,
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".go" || strings.HasSuffix(entry.Name(), "_test.go") {
|
||||
continue
|
||||
}
|
||||
parsed, err := parser.ParseFile(token.NewFileSet(), entry.Name(), nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, imported := range parsed.Imports {
|
||||
path, err := strconv.Unquote(imported.Path.Value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if forbiddenImports[path] {
|
||||
t.Errorf("%s imports forbidden capability %s", entry.Name(), path)
|
||||
}
|
||||
}
|
||||
ast.Inspect(parsed, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
selector, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || !forbiddenOSCalls[selector.Sel.Name] {
|
||||
return true
|
||||
}
|
||||
identifier, ok := selector.X.(*ast.Ident)
|
||||
if ok && identifier.Name == "os" {
|
||||
t.Errorf("%s calls forbidden filesystem mutation os.%s", entry.Name(), selector.Sel.Name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Package lsp implements Hime-san's read-only Language Server Protocol
|
||||
// adapter. It deliberately owns no generation, Go toolchain, HTTP, network,
|
||||
// or project-execution behavior.
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/compiler"
|
||||
)
|
||||
|
||||
// Options configures one stdio language-server process.
|
||||
type Options struct {
|
||||
Input io.Reader
|
||||
Output io.Writer
|
||||
LogOutput io.Writer
|
||||
Debounce time.Duration
|
||||
}
|
||||
|
||||
// Server serves exactly one workspace root.
|
||||
type Server struct {
|
||||
context context.Context
|
||||
cancel context.CancelFunc
|
||||
reader *frameReader
|
||||
writer *frameWriter
|
||||
logs io.Writer
|
||||
|
||||
mu sync.RWMutex
|
||||
root string
|
||||
initialized bool
|
||||
shutdown bool
|
||||
overlays map[string]document
|
||||
snapshot workspaceSnapshot
|
||||
analysisCancel context.CancelFunc
|
||||
analysisTimer *time.Timer
|
||||
analysisGeneration uint64
|
||||
debounce time.Duration
|
||||
afterFunc func(time.Duration, func()) *time.Timer
|
||||
requests map[string]context.CancelFunc
|
||||
wait sync.WaitGroup
|
||||
analysisWait sync.WaitGroup
|
||||
}
|
||||
|
||||
// Run serves LSP JSON-RPC until the client sends exit, closes stdin, or the
|
||||
// parent context is canceled.
|
||||
func Run(parent context.Context, options Options) error {
|
||||
if options.Input == nil || options.Output == nil {
|
||||
return errors.New("LSP stdin and stdout are required")
|
||||
}
|
||||
if options.LogOutput == nil {
|
||||
options.LogOutput = io.Discard
|
||||
}
|
||||
if options.Debounce <= 0 {
|
||||
options.Debounce = 200 * time.Millisecond
|
||||
}
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
server := &Server{
|
||||
context: ctx, cancel: cancel,
|
||||
reader: newFrameReader(options.Input), writer: &frameWriter{output: options.Output}, logs: options.LogOutput,
|
||||
overlays: make(map[string]document), snapshot: workspaceSnapshot{documents: make(map[string]document), analyses: make(map[string]compiler.DocumentAnalysis)},
|
||||
debounce: options.Debounce, afterFunc: time.AfterFunc, requests: make(map[string]context.CancelFunc),
|
||||
}
|
||||
defer func() {
|
||||
cancel()
|
||||
server.mu.Lock()
|
||||
server.analysisGeneration++
|
||||
if server.analysisTimer != nil {
|
||||
server.analysisTimer.Stop()
|
||||
}
|
||||
if server.analysisCancel != nil {
|
||||
server.analysisCancel()
|
||||
}
|
||||
for _, requestCancel := range server.requests {
|
||||
requestCancel()
|
||||
}
|
||||
server.mu.Unlock()
|
||||
server.wait.Wait()
|
||||
server.analysisWait.Wait()
|
||||
}()
|
||||
|
||||
for {
|
||||
payload, err := server.reader.read()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read LSP frame: %w", err)
|
||||
}
|
||||
var message rpcMessage
|
||||
if err := json.Unmarshal(payload, &message); err != nil {
|
||||
_ = server.writer.write(rpcMessage{JSONRPC: "2.0", ID: json.RawMessage("null"), Error: &rpcError{Code: errParse, Message: "invalid JSON"}})
|
||||
continue
|
||||
}
|
||||
if message.JSONRPC != "2.0" || message.Method == "" || !validRequestID(message.ID) {
|
||||
_ = server.writer.write(rpcMessage{JSONRPC: "2.0", ID: responseID(message.ID), Error: &rpcError{Code: errInvalidRequest, Message: "invalid JSON-RPC request"}})
|
||||
continue
|
||||
}
|
||||
if len(message.ID) == 0 {
|
||||
if message.Method == "exit" {
|
||||
server.cancel()
|
||||
return nil
|
||||
}
|
||||
server.handleNotification(message.Method, message.Params)
|
||||
continue
|
||||
}
|
||||
server.startRequest(message)
|
||||
}
|
||||
}
|
||||
|
||||
func responseID(id json.RawMessage) json.RawMessage {
|
||||
if len(id) == 0 {
|
||||
return json.RawMessage("null")
|
||||
}
|
||||
return append(json.RawMessage(nil), id...)
|
||||
}
|
||||
|
||||
func (server *Server) startRequest(message rpcMessage) {
|
||||
key := string(message.ID)
|
||||
ctx, cancel := context.WithCancel(server.context)
|
||||
server.mu.Lock()
|
||||
server.requests[key] = cancel
|
||||
server.mu.Unlock()
|
||||
server.wait.Add(1)
|
||||
go func() {
|
||||
defer server.wait.Done()
|
||||
defer cancel()
|
||||
result, rpcErr := server.handleRequest(ctx, message.Method, message.Params)
|
||||
if ctx.Err() != nil && rpcErr == nil {
|
||||
rpcErr = &rpcError{Code: errRequestCancelled, Message: "request canceled"}
|
||||
}
|
||||
if rpcErr == nil && result == nil {
|
||||
result = json.RawMessage("null")
|
||||
}
|
||||
server.mu.Lock()
|
||||
delete(server.requests, key)
|
||||
server.mu.Unlock()
|
||||
_ = server.writer.write(rpcMessage{JSONRPC: "2.0", ID: responseID(message.ID), Result: result, Error: rpcErr})
|
||||
}()
|
||||
}
|
||||
|
||||
func (server *Server) handleRequest(ctx context.Context, method string, params json.RawMessage) (any, *rpcError) {
|
||||
switch method {
|
||||
case "initialize":
|
||||
return server.initialize(params)
|
||||
case "shutdown":
|
||||
server.mu.Lock()
|
||||
server.shutdown = true
|
||||
if server.analysisCancel != nil {
|
||||
server.analysisCancel()
|
||||
}
|
||||
server.mu.Unlock()
|
||||
return nil, nil
|
||||
}
|
||||
server.mu.RLock()
|
||||
ready := server.initialized && !server.shutdown
|
||||
server.mu.RUnlock()
|
||||
if !ready {
|
||||
return nil, &rpcError{Code: errInvalidRequest, Message: "language server is not initialized"}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, &rpcError{Code: errRequestCancelled, Message: "request canceled"}
|
||||
default:
|
||||
}
|
||||
switch method {
|
||||
case "textDocument/completion":
|
||||
var request textDocumentPositionParams
|
||||
if err := json.Unmarshal(params, &request); err != nil {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "invalid completion parameters"}
|
||||
}
|
||||
return server.completions(request), nil
|
||||
case "textDocument/hover":
|
||||
var request textDocumentPositionParams
|
||||
if err := json.Unmarshal(params, &request); err != nil {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "invalid hover parameters"}
|
||||
}
|
||||
return server.hover(request), nil
|
||||
case "textDocument/definition":
|
||||
var request textDocumentPositionParams
|
||||
if err := json.Unmarshal(params, &request); err != nil {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "invalid definition parameters"}
|
||||
}
|
||||
return server.definition(request), nil
|
||||
case "textDocument/documentSymbol":
|
||||
var request struct {
|
||||
TextDocument textDocumentIdentifier `json:"textDocument"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &request); err != nil {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "invalid document-symbol parameters"}
|
||||
}
|
||||
return server.documentSymbols(request.TextDocument.URI), nil
|
||||
default:
|
||||
return nil, &rpcError{Code: errMethodNotFound, Message: "method not supported"}
|
||||
}
|
||||
}
|
||||
|
||||
func (server *Server) initialize(params json.RawMessage) (any, *rpcError) {
|
||||
var request struct {
|
||||
RootURI string `json:"rootUri"`
|
||||
RootPath string `json:"rootPath"`
|
||||
WorkspaceFolders []struct {
|
||||
URI string `json:"uri"`
|
||||
} `json:"workspaceFolders"`
|
||||
}
|
||||
if err := json.Unmarshal(params, &request); err != nil {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "invalid initialize parameters"}
|
||||
}
|
||||
if len(request.WorkspaceFolders) > 1 {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "Hime-san accepts one workspace folder per language-server process"}
|
||||
}
|
||||
rootURI := request.RootURI
|
||||
if len(request.WorkspaceFolders) == 1 {
|
||||
rootURI = request.WorkspaceFolders[0].URI
|
||||
}
|
||||
var root string
|
||||
var err error
|
||||
if rootURI != "" {
|
||||
root, err = fileURIToPath(rootURI)
|
||||
} else if request.RootPath != "" {
|
||||
root, err = filepath.Abs(request.RootPath)
|
||||
} else {
|
||||
root, err = os.Getwd()
|
||||
}
|
||||
if err != nil {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "workspace root is not a local filesystem path"}
|
||||
}
|
||||
info, err := os.Lstat(root)
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "workspace root must be an existing non-symlink directory"}
|
||||
}
|
||||
evaluated, err := filepath.EvalSymlinks(root)
|
||||
if err != nil || filepath.Clean(evaluated) != filepath.Clean(root) {
|
||||
return nil, &rpcError{Code: errInvalidParams, Message: "workspace roots reached through symlinks are not supported"}
|
||||
}
|
||||
server.mu.Lock()
|
||||
if server.initialized {
|
||||
server.mu.Unlock()
|
||||
return nil, &rpcError{Code: errInvalidRequest, Message: "initialize may be sent only once"}
|
||||
}
|
||||
server.root = filepath.Clean(root)
|
||||
server.initialized = true
|
||||
server.mu.Unlock()
|
||||
return struct {
|
||||
Capabilities any `json:"capabilities"`
|
||||
ServerInfo any `json:"serverInfo"`
|
||||
}{
|
||||
Capabilities: map[string]any{
|
||||
"positionEncoding": "utf-16",
|
||||
"textDocumentSync": map[string]any{"openClose": true, "change": 1, "save": map[string]any{"includeText": true}},
|
||||
"completionProvider": map[string]any{"triggerCharacters": []string{"<", "?", "~", "."}, "resolveProvider": false},
|
||||
"hoverProvider": true, "definitionProvider": true, "documentSymbolProvider": true,
|
||||
"workspace": map[string]any{"workspaceFolders": map[string]any{"supported": false, "changeNotifications": false}},
|
||||
},
|
||||
ServerInfo: map[string]any{"name": "himesan", "version": compiler.CompilerVersion},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (server *Server) handleNotification(method string, params json.RawMessage) {
|
||||
switch method {
|
||||
case "initialized":
|
||||
server.scheduleReindex(false)
|
||||
case "$/cancelRequest":
|
||||
var request struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(params, &request) == nil {
|
||||
server.mu.RLock()
|
||||
cancel := server.requests[string(request.ID)]
|
||||
server.mu.RUnlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
case "textDocument/didOpen":
|
||||
var request struct {
|
||||
TextDocument struct {
|
||||
URI string `json:"uri"`
|
||||
Version int `json:"version"`
|
||||
Text string `json:"text"`
|
||||
} `json:"textDocument"`
|
||||
}
|
||||
if json.Unmarshal(params, &request) == nil {
|
||||
server.updateOverlay(request.TextDocument.URI, request.TextDocument.Version, request.TextDocument.Text, true)
|
||||
server.scheduleReindex(false)
|
||||
}
|
||||
case "textDocument/didChange":
|
||||
var request struct {
|
||||
TextDocument versionedTextDocumentIdentifier `json:"textDocument"`
|
||||
ContentChanges []struct {
|
||||
Range *Range `json:"range,omitempty"`
|
||||
Text string `json:"text"`
|
||||
} `json:"contentChanges"`
|
||||
}
|
||||
if json.Unmarshal(params, &request) == nil && len(request.ContentChanges) != 0 {
|
||||
change := request.ContentChanges[len(request.ContentChanges)-1]
|
||||
if change.Range == nil {
|
||||
server.updateOverlay(request.TextDocument.URI, request.TextDocument.Version, change.Text, true)
|
||||
server.scheduleReindex(true)
|
||||
}
|
||||
}
|
||||
case "textDocument/didSave":
|
||||
var request struct {
|
||||
TextDocument textDocumentIdentifier `json:"textDocument"`
|
||||
Text *string `json:"text,omitempty"`
|
||||
}
|
||||
if json.Unmarshal(params, &request) == nil {
|
||||
if request.Text != nil {
|
||||
server.updateOverlay(request.TextDocument.URI, -1, *request.Text, true)
|
||||
}
|
||||
server.scheduleReindex(false)
|
||||
}
|
||||
case "textDocument/didClose":
|
||||
var request struct {
|
||||
TextDocument textDocumentIdentifier `json:"textDocument"`
|
||||
}
|
||||
if json.Unmarshal(params, &request) == nil {
|
||||
if path, err := fileURIToPath(request.TextDocument.URI); err == nil {
|
||||
server.mu.Lock()
|
||||
delete(server.overlays, path)
|
||||
server.mu.Unlock()
|
||||
server.scheduleReindex(false)
|
||||
}
|
||||
}
|
||||
case "workspace/didChangeWatchedFiles":
|
||||
server.scheduleReindex(false)
|
||||
}
|
||||
}
|
||||
|
||||
func (server *Server) updateOverlay(uri string, version int, text string, open bool) {
|
||||
if len(text) > maxDocumentBytes || !strings.HasSuffix(strings.ToLower(uri), ".sando") {
|
||||
return
|
||||
}
|
||||
path, err := fileURIToPath(uri)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
server.mu.Lock()
|
||||
defer server.mu.Unlock()
|
||||
if !editorPathAllowed(server.root, path) {
|
||||
return
|
||||
}
|
||||
if previous, ok := server.overlays[path]; ok && version < 0 {
|
||||
version = previous.Version
|
||||
}
|
||||
server.overlays[path] = document{URI: uri, Path: path, Text: []byte(text), Version: version, Open: open}
|
||||
}
|
||||
|
||||
func (server *Server) notify(method string, params any) error {
|
||||
payload, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return server.writer.write(rpcMessage{JSONRPC: "2.0", Method: method, Params: payload})
|
||||
}
|
||||
|
||||
func (server *Server) log(message string, count int) {
|
||||
// Logs deliberately contain only fixed messages and counts. Source text,
|
||||
// paths, environment values, and process details never cross this boundary.
|
||||
fmt.Fprintf(server.logs, "himesan lsp: %s (%d)\n", message, count)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/compiler"
|
||||
)
|
||||
|
||||
type protocolClient struct {
|
||||
input *io.PipeWriter
|
||||
output *frameReader
|
||||
nextID int
|
||||
lastPayload []byte
|
||||
}
|
||||
|
||||
func newProtocolClient(t *testing.T, root string) (*protocolClient, <-chan error) {
|
||||
t.Helper()
|
||||
serverInput, clientInput := io.Pipe()
|
||||
clientOutput, serverOutput := io.Pipe()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- Run(context.Background(), Options{Input: serverInput, Output: serverOutput, LogOutput: io.Discard, Debounce: 10 * time.Millisecond})
|
||||
_ = serverOutput.Close()
|
||||
}()
|
||||
client := &protocolClient{input: clientInput, output: newFrameReader(clientOutput)}
|
||||
response := client.call(t, "initialize", map[string]any{"rootUri": pathToURI(root)})
|
||||
if response.Error != nil {
|
||||
t.Fatalf("initialize: %#v", response.Error)
|
||||
}
|
||||
client.notify(t, "initialized", map[string]any{})
|
||||
return client, done
|
||||
}
|
||||
|
||||
func (client *protocolClient) send(t *testing.T, message rpcMessage) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
frame := append([]byte("Content-Length: "+itoa(len(payload))+"\r\n\r\n"), payload...)
|
||||
if _, err := client.input.Write(frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (client *protocolClient) call(t *testing.T, method string, params any) rpcMessage {
|
||||
t.Helper()
|
||||
client.nextID++
|
||||
id := client.nextID
|
||||
payload, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.send(t, rpcMessage{JSONRPC: "2.0", ID: json.RawMessage(itoa(id)), Method: method, Params: payload})
|
||||
for {
|
||||
message := client.read(t)
|
||||
if string(message.ID) == itoa(id) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (client *protocolClient) notify(t *testing.T, method string, params any) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.send(t, rpcMessage{JSONRPC: "2.0", Method: method, Params: payload})
|
||||
}
|
||||
|
||||
func (client *protocolClient) read(t *testing.T) rpcMessage {
|
||||
t.Helper()
|
||||
type result struct {
|
||||
message rpcMessage
|
||||
payload []byte
|
||||
err error
|
||||
}
|
||||
ready := make(chan result, 1)
|
||||
go func() {
|
||||
payload, err := client.output.read()
|
||||
if err != nil {
|
||||
ready <- result{err: err}
|
||||
return
|
||||
}
|
||||
var message rpcMessage
|
||||
err = json.Unmarshal(payload, &message)
|
||||
ready <- result{message: message, payload: payload, err: err}
|
||||
}()
|
||||
select {
|
||||
case got := <-ready:
|
||||
if got.err != nil {
|
||||
t.Fatal(got.err)
|
||||
}
|
||||
client.lastPayload = got.payload
|
||||
return got.message
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for language-server response")
|
||||
return rpcMessage{}
|
||||
}
|
||||
}
|
||||
|
||||
func (client *protocolClient) waitDiagnostics(t *testing.T, uri string, wantCode string) []lspDiagnostic {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
message := client.read(t)
|
||||
if message.Method != "textDocument/publishDiagnostics" {
|
||||
continue
|
||||
}
|
||||
var published struct {
|
||||
URI string `json:"uri"`
|
||||
Diagnostics []lspDiagnostic `json:"diagnostics"`
|
||||
}
|
||||
if json.Unmarshal(message.Params, &published) != nil || published.URI != uri {
|
||||
continue
|
||||
}
|
||||
if wantCode == "" {
|
||||
return published.Diagnostics
|
||||
}
|
||||
for _, item := range published.Diagnostics {
|
||||
if item.Code == wantCode {
|
||||
return published.Diagnostics
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s diagnostic", wantCode)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestServerOverlayFeaturesAndNoWrites(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "go.mod"), "module example.test/project\n\ngo 1.25\n")
|
||||
homePath := filepath.Join(root, "home.sando")
|
||||
badgePath := filepath.Join(root, "cards", "badge.sando")
|
||||
writeTestFile(t, homePath, "<?sando go\npackage views\nfunc Home(visitor string)\n?>\n<p><?= visitor ?></p>\n")
|
||||
writeTestFile(t, badgePath, "<?sando go\npackage cards\nfunc Badge(label string)\n?>\n<strong><?= label ?></strong>\n")
|
||||
|
||||
client, done := newProtocolClient(t, root)
|
||||
homeURI := pathToURI(homePath)
|
||||
client.waitDiagnostics(t, homeURI, "")
|
||||
overlay := "<?sando go\npackage views\nimport \"example.test/project/cards\"\nfunc Home(visitor string)\n?>\n<p>😀 <?= visitor ?></p>\n<?~ cards.Badge(\"new\") ?>\n"
|
||||
client.notify(t, "textDocument/didOpen", map[string]any{"textDocument": map[string]any{"uri": homeURI, "languageId": "sando", "version": 1, "text": overlay}})
|
||||
if diagnostics := client.waitDiagnostics(t, homeURI, ""); len(diagnostics) != 0 {
|
||||
t.Fatalf("valid overlay diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
completionOffset := strings.Index(overlay, "cards.Badge") + len("cards.B")
|
||||
completion := client.call(t, "textDocument/completion", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), completionOffset)})
|
||||
assertJSONContains(t, completion.Result, `"label":"cards.Badge"`)
|
||||
|
||||
definitionOffset := strings.Index(overlay, "Badge") + 2
|
||||
definition := client.call(t, "textDocument/definition", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), definitionOffset)})
|
||||
assertJSONContains(t, definition.Result, pathToURI(badgePath))
|
||||
|
||||
hoverOffset := strings.Index(overlay, "visitor ?></p>") + 2
|
||||
hover := client.call(t, "textDocument/hover", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), hoverOffset)})
|
||||
assertJSONContains(t, hover.Result, "html-text")
|
||||
|
||||
symbols := client.call(t, "textDocument/documentSymbol", map[string]any{"textDocument": map[string]string{"uri": homeURI}})
|
||||
assertJSONContains(t, symbols.Result, `"name":"Home"`)
|
||||
|
||||
if err := os.Remove(badgePath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client.notify(t, "workspace/didChangeWatchedFiles", map[string]any{"changes": []map[string]any{{"uri": pathToURI(badgePath), "type": 3}}})
|
||||
client.waitDiagnostics(t, pathToURI(badgePath), "")
|
||||
completion = client.call(t, "textDocument/completion", textDocumentPositionParams{TextDocument: textDocumentIdentifier{URI: homeURI}, Position: offsetToPosition([]byte(overlay), completionOffset)})
|
||||
if payload, _ := json.Marshal(completion.Result); strings.Contains(string(payload), `"label":"cards.Badge"`) {
|
||||
t.Fatalf("deleted component remained in completion index: %s", payload)
|
||||
}
|
||||
|
||||
broken := strings.Replace(overlay, "<?~ cards.Badge(\"new\") ?>", "<div>", 1)
|
||||
client.notify(t, "textDocument/didChange", map[string]any{
|
||||
"textDocument": map[string]any{"uri": homeURI, "version": 2},
|
||||
"contentChanges": []map[string]string{{"text": broken}},
|
||||
})
|
||||
client.waitDiagnostics(t, homeURI, "HIM1311")
|
||||
if _, err := os.Stat(homePath + ".go"); !os.IsNotExist(err) {
|
||||
t.Fatalf("language server wrote generated output: %v", err)
|
||||
}
|
||||
|
||||
shutdown := client.call(t, "shutdown", map[string]any{})
|
||||
if shutdown.Error != nil {
|
||||
t.Fatalf("shutdown: %#v", shutdown.Error)
|
||||
}
|
||||
if !bytes.Contains(client.lastPayload, []byte(`"result":null`)) {
|
||||
t.Fatalf("shutdown response omitted JSON-RPC null result: %s", client.lastPayload)
|
||||
}
|
||||
client.notify(t, "exit", map[string]any{})
|
||||
_ = client.input.Close()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("language server did not exit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayHonorsNestedModuleAndSymlinkBoundaries(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "go.mod"), "module example.test/root\n")
|
||||
nestedPath := filepath.Join(root, "nested", "view.sando")
|
||||
writeTestFile(t, filepath.Join(root, "nested", "go.mod"), "module example.test/nested\n")
|
||||
writeTestFile(t, nestedPath, "<?sando go\npackage nested\nfunc View()\n?>\n<p>no</p>\n")
|
||||
server := &Server{root: root, overlays: make(map[string]document)}
|
||||
server.updateOverlay(pathToURI(nestedPath), 1, "ignored", true)
|
||||
if len(server.overlays) != 0 {
|
||||
t.Fatalf("nested-module overlay was accepted: %#v", server.overlays)
|
||||
}
|
||||
|
||||
if runtime.GOOS != "windows" {
|
||||
realDirectory := filepath.Join(root, "real")
|
||||
if err := os.Mkdir(realDirectory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkDirectory := filepath.Join(root, "linked")
|
||||
if err := os.Symlink(realDirectory, linkDirectory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkedPath := filepath.Join(linkDirectory, "view.sando")
|
||||
server.updateOverlay(pathToURI(linkedPath), 1, "ignored", true)
|
||||
if len(server.overlays) != 0 {
|
||||
t.Fatalf("symlink overlay was accepted: %#v", server.overlays)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerRejectsMultipleRootsAndCanceledRequest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
server := &Server{initialized: true, snapshot: workspaceSnapshot{documents: map[string]document{}, analyses: map[string]compiler.DocumentAnalysis{}}}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, rpcErr := server.handleRequest(ctx, "textDocument/completion", json.RawMessage(`{}`))
|
||||
if rpcErr == nil || rpcErr.Code != errRequestCancelled {
|
||||
t.Fatalf("canceled request error = %#v", rpcErr)
|
||||
}
|
||||
input, writer := io.Pipe()
|
||||
reader, serverOutput := io.Pipe()
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- Run(context.Background(), Options{Input: input, Output: serverOutput}) }()
|
||||
client := &protocolClient{input: writer, output: newFrameReader(reader)}
|
||||
response := client.call(t, "initialize", map[string]any{"workspaceFolders": []map[string]string{{"uri": pathToURI(root)}, {"uri": pathToURI(root)}}})
|
||||
if response.Error == nil || response.Error.Code != errInvalidParams {
|
||||
t.Fatalf("multiple-root response = %#v", response)
|
||||
}
|
||||
client.notify(t, "exit", map[string]any{})
|
||||
_ = writer.Close()
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestReindexCountsOpenOverlaysInWorkspaceLimit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
server := &Server{
|
||||
root: root,
|
||||
overlays: make(map[string]document, maxWorkspaceFiles+1),
|
||||
snapshot: workspaceSnapshot{documents: map[string]document{}, analyses: map[string]compiler.DocumentAnalysis{}},
|
||||
writer: &frameWriter{output: io.Discard},
|
||||
logs: io.Discard,
|
||||
}
|
||||
for index := range maxWorkspaceFiles + 1 {
|
||||
path := filepath.Join(root, "overlay-"+itoa(index)+".sando")
|
||||
server.overlays[path] = document{URI: pathToURI(path), Path: path, Text: []byte("<?sando go\npackage views\nfunc View()\n?>\n"), Open: true}
|
||||
}
|
||||
err := server.reindex(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "more than 10000") {
|
||||
t.Fatalf("reindex overlay limit error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertJSONContains(t *testing.T, value any, marker string) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(payload), marker) {
|
||||
t.Fatalf("JSON %s does not contain %q", payload, marker)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(value int) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
var digits [20]byte
|
||||
index := len(digits)
|
||||
for value > 0 {
|
||||
index--
|
||||
digits[index] = byte('0' + value%10)
|
||||
value /= 10
|
||||
}
|
||||
return string(digits[index:])
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package lsp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf16"
|
||||
"unicode/utf8"
|
||||
|
||||
"gamertan.com/sandwich-hime/internal/compiler"
|
||||
)
|
||||
|
||||
const (
|
||||
maxDocumentBytes = 16 << 20
|
||||
maxWorkspaceBytes = 64 << 20
|
||||
maxWorkspaceFiles = 10000
|
||||
)
|
||||
|
||||
type document struct {
|
||||
URI string
|
||||
Path string
|
||||
Text []byte
|
||||
Version int
|
||||
Open bool
|
||||
}
|
||||
|
||||
type workspaceSnapshot struct {
|
||||
documents map[string]document
|
||||
analyses map[string]compiler.DocumentAnalysis
|
||||
moduleRoot string
|
||||
modulePath string
|
||||
}
|
||||
|
||||
func (server *Server) reindex(ctx context.Context) error {
|
||||
server.mu.RLock()
|
||||
root := server.root
|
||||
overlays := make(map[string]document, len(server.overlays))
|
||||
for path, item := range server.overlays {
|
||||
overlays[path] = item
|
||||
}
|
||||
server.mu.RUnlock()
|
||||
if root == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
paths, discoveryDiagnostics := compiler.DiscoverSources(ctx, []string{root})
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(paths) > maxWorkspaceFiles {
|
||||
return fmt.Errorf("workspace contains more than %d .sando files", maxWorkspaceFiles)
|
||||
}
|
||||
documents := make(map[string]document, len(paths)+len(overlays))
|
||||
total := 0
|
||||
for _, path := range paths {
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
absolute = filepath.Clean(absolute)
|
||||
if overlay, ok := overlays[absolute]; ok {
|
||||
documents[absolute] = overlay
|
||||
total += len(overlay.Text)
|
||||
continue
|
||||
}
|
||||
info, err := os.Lstat(absolute)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > maxDocumentBytes {
|
||||
continue
|
||||
}
|
||||
content, err := os.ReadFile(absolute)
|
||||
if err != nil || len(content) > maxDocumentBytes {
|
||||
continue
|
||||
}
|
||||
documents[absolute] = document{URI: pathToURI(absolute), Path: absolute, Text: content}
|
||||
total += len(content)
|
||||
if total > maxWorkspaceBytes {
|
||||
return fmt.Errorf("workspace .sando sources exceed %d bytes", maxWorkspaceBytes)
|
||||
}
|
||||
}
|
||||
for path, overlay := range overlays {
|
||||
if _, ok := documents[path]; ok {
|
||||
continue
|
||||
}
|
||||
if overlay.Open && editorPathAllowed(root, path) {
|
||||
documents[path] = overlay
|
||||
total += len(overlay.Text)
|
||||
}
|
||||
}
|
||||
if len(documents) > maxWorkspaceFiles {
|
||||
return fmt.Errorf("workspace contains more than %d .sando files", maxWorkspaceFiles)
|
||||
}
|
||||
if total > maxWorkspaceBytes {
|
||||
return fmt.Errorf("workspace .sando sources exceed %d bytes", maxWorkspaceBytes)
|
||||
}
|
||||
inputs := make([]compiler.SourceInput, 0, len(documents))
|
||||
for _, item := range documents {
|
||||
inputs = append(inputs, compiler.SourceInput{Path: item.Path, Source: item.Text})
|
||||
}
|
||||
analysed := compiler.AnalyzeSources(ctx, inputs)
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
analyses := make(map[string]compiler.DocumentAnalysis, len(analysed))
|
||||
for _, item := range analysed {
|
||||
analyses[filepath.Clean(item.Path)] = item
|
||||
}
|
||||
moduleRoot, modulePath := moduleIdentity(root)
|
||||
|
||||
server.mu.Lock()
|
||||
previous := server.snapshot.documents
|
||||
server.snapshot = workspaceSnapshot{documents: documents, analyses: analyses, moduleRoot: moduleRoot, modulePath: modulePath}
|
||||
server.mu.Unlock()
|
||||
|
||||
all := make(map[string]bool, len(previous)+len(documents))
|
||||
for path := range previous {
|
||||
all[path] = true
|
||||
}
|
||||
for path := range documents {
|
||||
all[path] = true
|
||||
}
|
||||
ordered := make([]string, 0, len(all))
|
||||
for path := range all {
|
||||
ordered = append(ordered, path)
|
||||
}
|
||||
sort.Strings(ordered)
|
||||
for _, path := range ordered {
|
||||
item, exists := documents[path]
|
||||
uri := pathToURI(path)
|
||||
diagnostics := make([]lspDiagnostic, 0)
|
||||
if exists {
|
||||
uri = item.URI
|
||||
for _, diagnostic := range analyses[path].Diagnostics {
|
||||
diagnostics = append(diagnostics, diagnosticToLSP(item.Text, diagnostic))
|
||||
}
|
||||
}
|
||||
if err := server.notify("textDocument/publishDiagnostics", struct {
|
||||
URI string `json:"uri"`
|
||||
Diagnostics []lspDiagnostic `json:"diagnostics"`
|
||||
}{URI: uri, Diagnostics: diagnostics}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(discoveryDiagnostics) != 0 {
|
||||
server.log("workspace discovery reported boundary diagnostics", len(discoveryDiagnostics))
|
||||
}
|
||||
server.log("analysis completed", len(documents))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (server *Server) scheduleReindex(delay bool) {
|
||||
server.mu.Lock()
|
||||
if server.analysisCancel != nil {
|
||||
server.analysisCancel()
|
||||
}
|
||||
if server.analysisTimer != nil {
|
||||
server.analysisTimer.Stop()
|
||||
}
|
||||
generation := server.analysisGeneration + 1
|
||||
server.analysisGeneration = generation
|
||||
wait := server.debounce
|
||||
if !delay {
|
||||
wait = 0
|
||||
}
|
||||
server.analysisTimer = server.afterFunc(wait, func() {
|
||||
ctx, cancel := context.WithCancel(server.context)
|
||||
server.mu.Lock()
|
||||
if server.analysisGeneration != generation {
|
||||
server.mu.Unlock()
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
server.analysisWait.Add(1)
|
||||
server.analysisCancel = cancel
|
||||
server.mu.Unlock()
|
||||
defer server.analysisWait.Done()
|
||||
err := server.reindex(ctx)
|
||||
cancel()
|
||||
server.mu.Lock()
|
||||
if server.analysisGeneration == generation {
|
||||
server.analysisCancel = nil
|
||||
}
|
||||
server.mu.Unlock()
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
server.log("analysis failed", 1)
|
||||
}
|
||||
})
|
||||
server.mu.Unlock()
|
||||
}
|
||||
|
||||
func (server *Server) snapshotDocument(uri string) (document, compiler.DocumentAnalysis, bool) {
|
||||
path, err := fileURIToPath(uri)
|
||||
if err != nil {
|
||||
return document{}, compiler.DocumentAnalysis{}, false
|
||||
}
|
||||
server.mu.RLock()
|
||||
defer server.mu.RUnlock()
|
||||
item, ok := server.snapshot.documents[path]
|
||||
if !ok {
|
||||
return document{}, compiler.DocumentAnalysis{}, false
|
||||
}
|
||||
return item, server.snapshot.analyses[path], true
|
||||
}
|
||||
|
||||
func moduleIdentity(root string) (string, string) {
|
||||
candidate := filepath.Join(root, "go.mod")
|
||||
content, err := os.ReadFile(candidate)
|
||||
if err != nil || len(content) > 1<<20 {
|
||||
return "", ""
|
||||
}
|
||||
for _, line := range strings.Split(string(content), "\n") {
|
||||
fields := strings.Fields(strings.TrimSpace(line))
|
||||
if len(fields) == 2 && fields[0] == "module" {
|
||||
return filepath.Clean(root), fields[1]
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func (snapshot workspaceSnapshot) packageImportPath(directory string) string {
|
||||
if snapshot.moduleRoot == "" || snapshot.modulePath == "" || !withinRoot(snapshot.moduleRoot, directory) {
|
||||
return ""
|
||||
}
|
||||
relative, err := filepath.Rel(snapshot.moduleRoot, directory)
|
||||
if err != nil || relative == "." {
|
||||
return snapshot.modulePath
|
||||
}
|
||||
return strings.TrimSuffix(snapshot.modulePath, "/") + "/" + filepath.ToSlash(relative)
|
||||
}
|
||||
|
||||
func (snapshot workspaceSnapshot) componentsFor(path string, analysis compiler.DocumentAnalysis) []componentTarget {
|
||||
directory := filepath.Dir(path)
|
||||
var targets []componentTarget
|
||||
for targetPath, targetAnalysis := range snapshot.analyses {
|
||||
if targetAnalysis.Component == "" {
|
||||
continue
|
||||
}
|
||||
targetDirectory := filepath.Dir(targetPath)
|
||||
if targetDirectory == directory && targetAnalysis.Package == analysis.Package {
|
||||
targets = append(targets, componentTarget{Name: targetAnalysis.Component, Signature: targetAnalysis.Signature, Path: targetPath, Analysis: targetAnalysis})
|
||||
continue
|
||||
}
|
||||
importPath := snapshot.packageImportPath(targetDirectory)
|
||||
for _, imported := range analysis.Imports {
|
||||
if imported.Path != importPath || imported.Alias == "_" || imported.Alias == "." {
|
||||
continue
|
||||
}
|
||||
alias := imported.Alias
|
||||
if alias == "" {
|
||||
alias = targetAnalysis.Package
|
||||
}
|
||||
targets = append(targets, componentTarget{Qualifier: alias, Name: targetAnalysis.Component, Signature: targetAnalysis.Signature, Path: targetPath, Analysis: targetAnalysis})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(targets, func(i, j int) bool {
|
||||
return targets[i].Label() < targets[j].Label()
|
||||
})
|
||||
return targets
|
||||
}
|
||||
|
||||
type componentTarget struct {
|
||||
Qualifier string
|
||||
Name string
|
||||
Signature string
|
||||
Path string
|
||||
Analysis compiler.DocumentAnalysis
|
||||
}
|
||||
|
||||
func (target componentTarget) Label() string {
|
||||
if target.Qualifier == "" {
|
||||
return target.Name
|
||||
}
|
||||
return target.Qualifier + "." + target.Name
|
||||
}
|
||||
|
||||
func withinRoot(root, path string) bool {
|
||||
relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
|
||||
return err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
|
||||
}
|
||||
|
||||
func editorPathAllowed(root, path string) bool {
|
||||
if filepath.Ext(path) != ".sando" || !withinRoot(root, path) {
|
||||
return false
|
||||
}
|
||||
relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
current := filepath.Clean(root)
|
||||
parts := strings.Split(relative, string(filepath.Separator))
|
||||
for index, part := range parts {
|
||||
if part == "" || part == "." {
|
||||
continue
|
||||
}
|
||||
if index < len(parts)-1 && (part == ".git" || part == ".hg" || part == ".svn" || part == "vendor") {
|
||||
return false
|
||||
}
|
||||
current = filepath.Join(current, part)
|
||||
info, statErr := os.Lstat(current)
|
||||
if statErr == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return false
|
||||
}
|
||||
if statErr != nil && !os.IsNotExist(statErr) {
|
||||
return false
|
||||
}
|
||||
if index < len(parts)-1 && current != filepath.Clean(root) {
|
||||
moduleInfo, moduleErr := os.Lstat(filepath.Join(current, "go.mod"))
|
||||
if moduleErr == nil || moduleInfo != nil {
|
||||
return false
|
||||
}
|
||||
if moduleErr != nil && !os.IsNotExist(moduleErr) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fileURIToPath(value string) (string, error) {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme != "file" || (parsed.Host != "" && parsed.Host != "localhost") {
|
||||
return "", errors.New("only local file URIs are supported")
|
||||
}
|
||||
path, err := url.PathUnescape(parsed.EscapedPath())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if runtime.GOOS == "windows" && len(path) >= 3 && path[0] == '/' && path[2] == ':' {
|
||||
path = path[1:]
|
||||
}
|
||||
absolute, err := filepath.Abs(filepath.FromSlash(path))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(absolute), nil
|
||||
}
|
||||
|
||||
func pathToURI(path string) string {
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
absolute = filepath.Clean(path)
|
||||
}
|
||||
slashed := filepath.ToSlash(absolute)
|
||||
if runtime.GOOS == "windows" && !strings.HasPrefix(slashed, "/") {
|
||||
slashed = "/" + slashed
|
||||
}
|
||||
return (&url.URL{Scheme: "file", Path: slashed}).String()
|
||||
}
|
||||
|
||||
func offsetToPosition(text []byte, offset int) Position {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset > len(text) {
|
||||
offset = len(text)
|
||||
}
|
||||
line, character := 0, 0
|
||||
for index := 0; index < offset; {
|
||||
if text[index] == '\n' {
|
||||
line++
|
||||
character = 0
|
||||
index++
|
||||
continue
|
||||
}
|
||||
r, size := utf8.DecodeRune(text[index:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
character++
|
||||
index++
|
||||
continue
|
||||
}
|
||||
character += len(utf16.Encode([]rune{r}))
|
||||
index += size
|
||||
}
|
||||
return Position{Line: line, Character: character}
|
||||
}
|
||||
|
||||
func positionToOffset(text []byte, position Position) (int, bool) {
|
||||
if position.Line < 0 || position.Character < 0 {
|
||||
return 0, false
|
||||
}
|
||||
line := 0
|
||||
start := 0
|
||||
for start < len(text) && line < position.Line {
|
||||
if text[start] == '\n' {
|
||||
line++
|
||||
}
|
||||
start++
|
||||
}
|
||||
if line != position.Line {
|
||||
return 0, false
|
||||
}
|
||||
units := 0
|
||||
for index := start; index < len(text) && text[index] != '\n'; {
|
||||
if units == position.Character {
|
||||
return index, true
|
||||
}
|
||||
r, size := utf8.DecodeRune(text[index:])
|
||||
if r == utf8.RuneError && size == 1 {
|
||||
units++
|
||||
index++
|
||||
} else {
|
||||
units += len(utf16.Encode([]rune{r}))
|
||||
index += size
|
||||
}
|
||||
if units > position.Character {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
if units == position.Character {
|
||||
index := start
|
||||
for index < len(text) && text[index] != '\n' {
|
||||
index++
|
||||
}
|
||||
return index, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func compilerPositionOffset(text []byte, line, column int) int {
|
||||
if line < 1 {
|
||||
line = 1
|
||||
}
|
||||
if column < 1 {
|
||||
column = 1
|
||||
}
|
||||
start := 0
|
||||
for current := 1; current < line && start < len(text); current++ {
|
||||
newline := strings.IndexByte(string(text[start:]), '\n')
|
||||
if newline < 0 {
|
||||
return len(text)
|
||||
}
|
||||
start += newline + 1
|
||||
}
|
||||
offset := start + column - 1
|
||||
if offset > len(text) {
|
||||
offset = len(text)
|
||||
}
|
||||
return offset
|
||||
}
|
||||
|
||||
func diagnosticToLSP(text []byte, diagnostic compiler.Diagnostic) lspDiagnostic {
|
||||
startOffset := compilerPositionOffset(text, diagnostic.Line, diagnostic.Column)
|
||||
endOffset := startOffset
|
||||
if endOffset < len(text) {
|
||||
_, size := utf8.DecodeRune(text[endOffset:])
|
||||
if size < 1 {
|
||||
size = 1
|
||||
}
|
||||
endOffset += size
|
||||
}
|
||||
severity := 1
|
||||
if diagnostic.Severity == compiler.SeverityWarning {
|
||||
severity = 2
|
||||
}
|
||||
return lspDiagnostic{
|
||||
Range: Range{Start: offsetToPosition(text, startOffset), End: offsetToPosition(text, endOffset)},
|
||||
Severity: severity, Code: diagnostic.Code, Source: "himesan", Message: diagnostic.Message,
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ func TestSelectCompilerVersion(t *testing.T) {
|
||||
{name: "missing build info", linkerValue: developmentCompilerVersion, moduleVersion: "", want: developmentCompilerVersion},
|
||||
{name: "versioned go install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0", want: "v1.0.0"},
|
||||
{name: "beta launch install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta.1", want: "v1.0.0-beta.1"},
|
||||
{name: "beta two install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta.2", want: "v1.0.0-beta.2"},
|
||||
{name: "versioned prerelease install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-rc.1", want: "v1.0.0-rc.1"},
|
||||
{name: "hyphenated prerelease install", linkerValue: developmentCompilerVersion, moduleVersion: "v1.2.3-beta-2", want: "v1.2.3-beta-2"},
|
||||
{name: "pseudo version", linkerValue: developmentCompilerVersion, moduleVersion: "v0.0.0-20260811120000-0123456789ab", want: developmentCompilerVersion},
|
||||
@@ -30,6 +31,7 @@ func TestSelectCompilerVersion(t *testing.T) {
|
||||
{name: "leading zero numeric beta identifier", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta.01", want: developmentCompilerVersion},
|
||||
{name: "empty beta identifier", linkerValue: developmentCompilerVersion, moduleVersion: "v1.0.0-beta..1", want: developmentCompilerVersion},
|
||||
{name: "beta linker override wins", linkerValue: "v1.0.0-beta.1", moduleVersion: "(devel)", want: "v1.0.0-beta.1"},
|
||||
{name: "beta two linker override wins", linkerValue: "v1.0.0-beta.2", moduleVersion: "(devel)", want: "v1.0.0-beta.2"},
|
||||
{name: "linker override wins", linkerValue: "v1.0.0-rc.1", moduleVersion: "v1.0.0", want: "v1.0.0-rc.1"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ These scripts are intentionally understandable shell and PowerShell rather than
|
||||
- `verify.ps1` provides the equivalent native Windows lane; pass `-Race` to include the race detector.
|
||||
- `check-licenses.sh` enforces the AGPL compiler / Apache runtime boundary and prevents generated application Go from inheriting an AGPL identifier.
|
||||
- `release-check.sh --version vX.Y.Z` is a clean-checkout technical preflight, including exact candidate-version and generated-provenance checks. Beta publication follows the narrower prerelease gates in `RELEASE.md`; release candidates and final v1 additionally use `--public` with a human-reviewed `HIMESAN_RELEASE_EVIDENCE_DIR`. The script never tags, pushes, publishes, or deploys.
|
||||
- `verify-public-install.sh --version vX.Y.Z` is a post-tag/publication check. It verifies exact `go-get=1` package routes and runs the documented compiler install and runtime get from fresh direct-fetch and public-proxy caches without interactive Git credentials.
|
||||
- `verify-public-install.sh --version vX.Y.Z` is a post-tag/publication check. It verifies exact `go-get=1` package routes, adds the nested runtime before installing the parent compiler, and exercises fresh direct-fetch and public-proxy caches without interactive Git credentials.
|
||||
|
||||
The canonical Linux CI and release preflight also run bounded fuzz sessions for the parser/context compiler and Go-aware delimiter scanner. Seed-corpus execution remains part of ordinary `go test`; the bounded sessions are extra evidence, not a substitute for longer scheduled fuzzing before v1.
|
||||
|
||||
|
||||
@@ -8,13 +8,14 @@ cd "$repo_root"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/release-check.sh --version vX.Y.Z [--public]
|
||||
Usage: scripts/release-check.sh --version vX.Y.Z [--runtime-tag sando/vX.Y.Z] [--public]
|
||||
|
||||
Runs a read-only release preflight. It never creates tags, commits, release
|
||||
artifacts in the repository, pushes, or deploys.
|
||||
|
||||
--version Candidate compiler version. The corresponding runtime tag is
|
||||
sando/vX.Y.Z.
|
||||
--version Candidate compiler version.
|
||||
--runtime-tag Existing runtime tag retained by a compiler-only release.
|
||||
Omit only when publishing a matching new runtime tag.
|
||||
--public Require the human-reviewed RC/final launch evidence bundle named
|
||||
by HIMESAN_RELEASE_EVIDENCE_DIR. Canonical beta prereleases may
|
||||
run their narrower publication preflight without this flag.
|
||||
@@ -22,6 +23,7 @@ EOF
|
||||
}
|
||||
|
||||
version=''
|
||||
runtime_tag=''
|
||||
public_release=0
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
@@ -30,6 +32,11 @@ while (( $# > 0 )); do
|
||||
version=$2
|
||||
shift 2
|
||||
;;
|
||||
--runtime-tag)
|
||||
[[ $# -ge 2 ]] || { usage >&2; exit 2; }
|
||||
runtime_tag=$2
|
||||
shift 2
|
||||
;;
|
||||
--public)
|
||||
public_release=1
|
||||
shift
|
||||
@@ -74,7 +81,32 @@ if (( public_release == 0 && beta_release == 0 )); then
|
||||
exit 2
|
||||
fi
|
||||
|
||||
runtime_tag="sando/$version"
|
||||
paired_runtime_tag="sando/$version"
|
||||
compiler_only=0
|
||||
if [[ -z "$runtime_tag" ]]; then
|
||||
runtime_tag=$paired_runtime_tag
|
||||
elif [[ ! "$runtime_tag" =~ ^sando/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$ ]]; then
|
||||
printf 'error: --runtime-tag must be a canonical sando/v semantic version without build metadata\n' >&2
|
||||
exit 2
|
||||
else
|
||||
runtime_prerelease=${BASH_REMATCH[5]:-}
|
||||
if [[ "$runtime_prerelease" =~ (^|[.-])(0\.)?[0-9]{14}-[0-9a-f]{12,}$ ]]; then
|
||||
printf 'error: --runtime-tag must identify a signed release, not a Go pseudo-version\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ -n "$runtime_prerelease" ]]; then
|
||||
IFS=. read -r -a runtime_prerelease_identifiers <<<"$runtime_prerelease"
|
||||
for identifier in "${runtime_prerelease_identifiers[@]}"; do
|
||||
if [[ "$identifier" =~ ^[0-9]+$ && "$identifier" =~ ^0[0-9]+$ ]]; then
|
||||
printf 'error: runtime numeric prerelease identifiers must not contain leading zeroes: %s\n' "$identifier" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
fi
|
||||
if [[ "$runtime_tag" != "$paired_runtime_tag" ]]; then
|
||||
compiler_only=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$(git status --porcelain=v1 --untracked-files=all)" ]]; then
|
||||
printf 'error: release preflight requires a clean canonical checkout\n' >&2
|
||||
@@ -99,7 +131,11 @@ if [[ "$branch" != main ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for tag in "$version" "$runtime_tag"; do
|
||||
candidate_tags=("$version")
|
||||
if (( compiler_only == 0 )); then
|
||||
candidate_tags+=("$runtime_tag")
|
||||
fi
|
||||
for tag in "${candidate_tags[@]}"; do
|
||||
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
|
||||
printf 'error: candidate tag already exists locally: %s\n' "$tag" >&2
|
||||
exit 1
|
||||
@@ -114,6 +150,26 @@ for tag in "$version" "$runtime_tag"; do
|
||||
fi
|
||||
done
|
||||
|
||||
if (( compiler_only == 1 )); then
|
||||
if ! runtime_refs=$(git ls-remote --tags origin "refs/tags/$runtime_tag" "refs/tags/$runtime_tag^{}" 2>/dev/null); then
|
||||
printf 'error: could not verify retained runtime tag against canonical origin: %s\n' "$runtime_tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
runtime_commit=$(printf '%s\n' "$runtime_refs" | awk '$2 ~ /\^\{\}$/ {print $1}')
|
||||
if [[ -z "$runtime_commit" || ! "$runtime_commit" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
printf 'error: retained runtime tag is absent or is not annotated: %s\n' "$runtime_tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git cat-file -e "$runtime_commit^{commit}" 2>/dev/null; then
|
||||
printf 'error: retained runtime tag commit is not present in canonical history: %s\n' "$runtime_tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$(git rev-parse HEAD:sando)" != "$(git rev-parse "$runtime_commit:sando")" ]]; then
|
||||
printf 'error: compiler-only release changed the sando subtree retained at %s\n' "$runtime_tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
artifact_dir=$(mktemp -d "${TMPDIR:-/tmp}/himesan-release-check.XXXXXXXX")
|
||||
cleanup() {
|
||||
if [[ -n "${artifact_dir:-}" && -d "$artifact_dir" ]]; then
|
||||
@@ -135,7 +191,7 @@ if [[ "$actual_human_version" != "$expected_human_version" ]]; then
|
||||
"$expected_human_version" "$actual_human_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
expected_json_version=$(printf '{"compiler":"%s","runtime_abi":"sando.v1","go":"%s"}' "$version" "$candidate_go_version")
|
||||
expected_json_version=$(printf '{"compiler":"%s","runtime_abi":"sando.v1","go":"%s","features":["lsp-stdio"]}' "$version" "$candidate_go_version")
|
||||
actual_json_version=$("$candidate_binary" version --json)
|
||||
if [[ "$actual_json_version" != "$expected_json_version" ]]; then
|
||||
printf 'error: candidate JSON version mismatch\nexpected: %s\nactual: %s\n' \
|
||||
@@ -187,6 +243,8 @@ HIMESAN_RACE=1 ./scripts/verify.sh
|
||||
printf '\n==> bounded compiler fuzz gates\n'
|
||||
go test ./internal/compiler -run '^$' -fuzz '^FuzzCompileNeverPanics$' -fuzztime=20s
|
||||
go test ./internal/compiler -run '^$' -fuzz '^FuzzGoDelimiterNeverPanics$' -fuzztime=20s
|
||||
go test ./internal/lsp -run '^$' -fuzz '^FuzzFrameReaderNeverPanics$' -fuzztime=20s
|
||||
go test ./internal/lsp -run '^$' -fuzz '^FuzzDocumentPositionNeverPanics$' -fuzztime=20s
|
||||
|
||||
printf '\n==> vulnerability scan (pinned golang.org/x/vuln v1.6.0)\n'
|
||||
go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
|
||||
@@ -256,4 +314,8 @@ else
|
||||
printf '\nBeta technical publication preflight passed. This does not establish RC/final launch evidence or production stability.\n'
|
||||
fi
|
||||
|
||||
printf 'No tag, push, publication, or deployment was performed for %s / %s.\n' "$version" "$runtime_tag"
|
||||
if (( compiler_only == 1 )); then
|
||||
printf 'No tag, push, publication, or deployment was performed for %s; runtime remains %s.\n' "$version" "$runtime_tag"
|
||||
else
|
||||
printf 'No tag, push, publication, or deployment was performed for %s / %s.\n' "$version" "$runtime_tag"
|
||||
fi
|
||||
|
||||
@@ -103,6 +103,7 @@ fi
|
||||
scratch_dir=$(mktemp -d "${TMPDIR:-/tmp}/himesan-public-install.XXXXXXXX")
|
||||
cleanup() {
|
||||
if [[ -n "${scratch_dir:-}" && -d "$scratch_dir" ]]; then
|
||||
chmod -R u+w -- "$scratch_dir" 2>/dev/null || true
|
||||
rm -rf -- "$scratch_dir"
|
||||
fi
|
||||
}
|
||||
@@ -116,7 +117,29 @@ run_install_pair() {
|
||||
local installed_binary installed_version go_executable_suffix
|
||||
mkdir -p "$mode_dir/gopath" "$mode_dir/modcache" "$mode_dir/buildcache" "$mode_dir/consumer"
|
||||
|
||||
printf '\n==> %s clean-cache install\n' "$mode"
|
||||
printf '\n==> %s clean-cache runtime then compiler install\n' "$mode"
|
||||
(
|
||||
cd "$mode_dir/consumer"
|
||||
go mod init example.invalid/himesan-public-install >/dev/null
|
||||
env \
|
||||
GIT_TERMINAL_PROMPT=0 \
|
||||
GIT_CONFIG_NOSYSTEM=1 \
|
||||
GIT_CONFIG_GLOBAL=/dev/null \
|
||||
GIT_ASKPASS="$false_command" \
|
||||
SSH_ASKPASS="$false_command" \
|
||||
GOPATH="$mode_dir/gopath" \
|
||||
GOMODCACHE="$mode_dir/modcache" \
|
||||
GOCACHE="$mode_dir/buildcache" \
|
||||
GOPROXY="$proxy" \
|
||||
GOPRIVATE= \
|
||||
GONOPROXY=none \
|
||||
GONOSUMDB="$no_sum_db" \
|
||||
GOSUMDB=sum.golang.org \
|
||||
GOINSECURE= \
|
||||
GOAUTH=off \
|
||||
go get "gamertan.com/sandwich-hime/sando@$version"
|
||||
)
|
||||
|
||||
env \
|
||||
GIT_TERMINAL_PROMPT=0 \
|
||||
GIT_CONFIG_NOSYSTEM=1 \
|
||||
@@ -154,28 +177,6 @@ EOF
|
||||
printf 'error: generated provenance did not record installed compiler version %s\n' "$version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$mode_dir/consumer"
|
||||
go mod init example.invalid/himesan-public-install >/dev/null
|
||||
env \
|
||||
GIT_TERMINAL_PROMPT=0 \
|
||||
GIT_CONFIG_NOSYSTEM=1 \
|
||||
GIT_CONFIG_GLOBAL=/dev/null \
|
||||
GIT_ASKPASS="$false_command" \
|
||||
SSH_ASKPASS="$false_command" \
|
||||
GOPATH="$mode_dir/gopath" \
|
||||
GOMODCACHE="$mode_dir/modcache" \
|
||||
GOCACHE="$mode_dir/buildcache" \
|
||||
GOPROXY="$proxy" \
|
||||
GOPRIVATE= \
|
||||
GONOPROXY=none \
|
||||
GONOSUMDB="$no_sum_db" \
|
||||
GOSUMDB=sum.golang.org \
|
||||
GOINSECURE= \
|
||||
GOAUTH=off \
|
||||
go get "gamertan.com/sandwich-hime/sando@$version"
|
||||
)
|
||||
}
|
||||
|
||||
run_install_pair direct direct gamertan.com/sandwich-hime
|
||||
|
||||
Reference in New Issue
Block a user