diff --git a/Justfile b/Justfile index 8584502..302cc40 100644 --- a/Justfile +++ b/Justfile @@ -70,11 +70,16 @@ gui: # === TESTING === -# Run all tests +# Run all tests (cartridge invariants + GUI smoke test) test: @echo "๐Ÿงช Running tests..." deno task test +# Run just the GUI smoke test (server handler: shell, bundle, cartridge API) +test-gui: + @echo "๐Ÿ–ฅ๏ธ GUI smoke test..." + @deno task test:gui + # === VALIDATION === # Run full validation diff --git a/TEST-NEEDS.adoc b/TEST-NEEDS.adoc new file mode 100644 index 0000000..d843d7b --- /dev/null +++ b/TEST-NEEDS.adoc @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 += TEST-NEEDS โ€” nextgen-languages-evangeliser + +CRG Grade: C (achieved 2026-04-04). + +== Current test state + +[cols="2,1,3",options="header"] +|=== +| Category | Count | Notes + +| Cartridge invariant tests | 1 suite | `test/run_all.js` (Deno) โ€” semantic invariants over every cartridge +| GUI smoke test | 1 suite | `test/gui_smoke_test.js` (Deno) โ€” server handler: shell, bundle, cartridge API +| Cartridge schema validation | 1 | `scripts/validate-cartridges.js` (Deno + ajv, JSON-Schema 2020-12) +| Idris2 ABI typecheck | 1 | `abi.ipkg` / `src/interface/Abi/*.idr` (CI: `idris2-abi.yml`) +| Test framework | Deno + ajv + Idris2 | host-language unit tests were removed with the ReScript host +|=== + +== What's covered + +* Every correspondence is classified by one of the six CorrespondenceKinds +* Well-formedness: Forms (language + surface), residue shapes, strata names/booleans +* False-friend signature (surface holds โˆง intention diverges) โ€” mirrors `Abi.Correspondence.isFalseFriendShape` +* Residue direction for novel (absent-source) / vanished (absent-target) +* The worked-examples reference cartridge demonstrates all six kinds +* Cartridge JSON-Schema conformance (ajv) +* GUI server handler: static shell, `/app.js` bundle, `/api/cartridges` shape, and a clean 404 +* Idris2 ABI typechecks (no `believe_me` / `assert_total`) + +== Still missing (for CRG B+) + +* Headless-DOM rendering test of `gui/app.js` (the smoke test covers the server / API, not the browser render) +* Property-based generation over cartridge facts +* Performance benchmarks +* End-to-end "walk a correspondence" flow test + +== Run tests + +[source,bash] +---- +just test # cartridge invariants + GUI smoke test +just test-gui # GUI smoke test only +deno task test # same as `just test` +deno task validate # cartridge schema validation +---- diff --git a/TEST-NEEDS.md b/TEST-NEEDS.md deleted file mode 100644 index ea9ed7b..0000000 --- a/TEST-NEEDS.md +++ /dev/null @@ -1,37 +0,0 @@ -# TEST-NEEDS.md โ€” nextgen-languages-evangeliser - -## CRG Grade: C โ€” ACHIEVED 2026-04-04 - -## Current Test State - -| Category | Count | Notes | -|----------|-------|-------| -| Cartridge invariant tests | 1 suite | `test/run_all.js` (Deno) โ€” 59 assertions over the correspondence facts | -| Cartridge schema validation | 1 | `scripts/validate-cartridges.js` (Deno + ajv, JSON-Schema 2020-12) | -| Idris2 ABI typecheck | 1 | `abi.ipkg` / `src/interface/Abi/*.idr` (CI: idris2-abi.yml) | -| Test framework | Deno + ajv + Idris2 | host-language unit tests removed with the ReScript host | - -## What's Covered - -- [x] Every correspondence is classified by one of the six CorrespondenceKinds -- [x] Well-formedness: Forms (language + surface), residue shapes, strata names/booleans -- [x] False-friend signature (surface holds โˆง intention diverges) โ€” mirrors `Abi.Correspondence.isFalseFriendShape` -- [x] Residue direction for novel (absent-source) / vanished (absent-target) -- [x] Reference cartridge demonstrates all six kinds -- [x] Cartridge JSON-Schema conformance (ajv) -- [x] Idris2 ABI typechecks (no `believe_me` / `assert_total`) - -## Still Missing (for CRG B+) - -- [ ] Property-based pattern generation -- [ ] Integration tests with external tools -- [ ] Performance benchmarks -- [ ] End-to-end evangelism flow tests - -## Run Tests - -```bash -deno task test # cartridge invariant tests -deno task validate # cartridge schema validation -just test # same, via the Justfile -``` diff --git a/TOPOLOGY.adoc b/TOPOLOGY.adoc new file mode 100644 index 0000000..9dfff32 --- /dev/null +++ b/TOPOLOGY.adoc @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += TOPOLOGY โ€” nextgen-languages-evangeliser + +== Purpose + +Cross-language *comprehension and transfer* engine โ€” Duolingo / Rosetta Stone for +programming languages. It *classifies* correspondences between a known language and +a target so learning transfers (it does not translate, lint, or build IDEs). +Flagship / future host: AffineScript. Legacy target: ReScript (host removed). Runs +on Deno; the browser multi-pane workspace is the primary surface. + +== Module map + +[source] +---- +nextgen-languages-evangeliser/ +โ”œโ”€โ”€ bin/evangeliser.js # Offline CLI (Deno; reads cartridges) +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ cartridges.js # Shared cartridge loader (Deno glue) +โ”‚ โ””โ”€โ”€ interface/ # Engine spine: Idris2 ABI + Zig FFI + AffineScript host binding +โ”œโ”€โ”€ cartridges/ # Correspondence facts: schema + reference pack +โ”œโ”€โ”€ gui/ # Browser multi-pane workspace (primary surface) +โ”œโ”€โ”€ scripts/ # Deno utilities (cartridge validator, GUI launcher) +โ”œโ”€โ”€ test/ # Cartridge invariant tests + GUI smoke test +โ”œโ”€โ”€ config.ncl # Nickel configuration +โ”œโ”€โ”€ docs/ # Documentation (AsciiDoc) +โ”œโ”€โ”€ Justfile # Task runner recipes +โ””โ”€โ”€ deno.json # Deno module config +---- + +== Data flow + +[source] +---- +cartridges/**/*.cartridge.json (authored correspondence facts) + โ”‚ + โ–ผ + shared loader (src/cartridges.js) + โ”‚ + โ”œโ”€โ–บ gui/server.js โ”€โ”€โ–บ browser workspace (index ยท forms ยท classification) + โ”œโ”€โ–บ bin/evangeliser.js โ”€โ”€โ–บ CLI (--kind / --find / --json) + โ””โ”€โ–บ test + scripts โ”€โ”€โ–บ invariants + schema validation + โ”‚ + โ–ผ + (downstream, planned) octads โ”€โ”€โ–บ VeriSimDB โ”€โ”€โ–บ PanLL panels +---- + +The classification vocabulary (six CorrespondenceKinds, strata, residue shapes) is +specified in the Idris2 ABI (`src/interface/Abi/`) and mirrored by the cartridge +schema. The engine *classifies* correspondences; it does not transform source. diff --git a/TOPOLOGY.md b/TOPOLOGY.md deleted file mode 100644 index 2218758..0000000 --- a/TOPOLOGY.md +++ /dev/null @@ -1,30 +0,0 @@ - - -# TOPOLOGY.md โ€” nextgen-languages-evangeliser - -## Purpose - -Cross-language *comprehension and transfer* engine โ€” Duolingo / Rosetta Stone for programming languages. It *classifies* correspondences between a known language and a target so learning transfers (it does not translate, lint, or build IDEs). Flagship / future host: AffineScript. Legacy target: ReScript (host removed). Runs on Deno; the browser multi-pane workspace is the primary surface. - -## Module Map - -``` -nextgen-languages-evangeliser/ -โ”œโ”€โ”€ bin/ -โ”‚ โ””โ”€โ”€ evangeliser.js # Offline CLI (Deno; reads cartridges) -โ”œโ”€โ”€ src/interface/ # Engine spine: Idris2 ABI + Zig FFI + AffineScript host binding -โ”œโ”€โ”€ cartridges/ # Correspondence facts (schema + reference pack) -โ”œโ”€โ”€ gui/ # Browser multi-pane workspace (primary surface) -โ”œโ”€โ”€ config.ncl # Nickel configuration -โ”œโ”€โ”€ docs/ # Usage and output documentation -โ”œโ”€โ”€ Justfile # Task runner recipes -โ””โ”€โ”€ deno.json # Deno module config -``` - -## Data Flow - -``` -[JavaScript/TypeScript codebase] โ”€โ”€โ–บ [evangeliser.js analysis] โ”€โ”€โ–บ [Migration report (per target)] - [config.ncl] โ”€โ”€โ–บ โ”‚ - [Multi-target advocacy content generator] โ”€โ”€โ–บ [Reports / stats] -``` diff --git a/cartridges/reference/python-ruby.cartridge.json b/cartridges/reference/python-ruby.cartridge.json new file mode 100644 index 0000000..069c544 --- /dev/null +++ b/cartridges/reference/python-ruby.cartridge.json @@ -0,0 +1,122 @@ +{ + "$schema": "../correspondence-cartridge.schema.json", + "spdx": "MPL-2.0", + "name": "python-ruby", + "version": "0.1.0", + "description": "Second reference cartridge: Python -> Ruby. Dogfoods the cartridge contract on a fresh language pair to show the engine is language-agnostic. Covers cognate / false-friend / alien-realization / novel / vanished from a Python learner's standpoint.", + "languages": ["python", "ruby"], + "transitions": [ + { + "concept": "function-definition", + "from": { "language": "python", "surface": "def greet(name):\n ..." }, + "to": { "language": "ruby", "surface": "def greet(name)\n ...\nend" }, + "kind": "cognate", + "residue": { + "shape": "none", + "note": "Same keyword (`def`) and intent; Ruby closes the body with `end` where Python uses indentation." + }, + "strata": [ + { "stratum": "surface", "holds": true }, + { "stratum": "intention", "holds": true } + ], + "narrative": { + "celebrate": "You already define functions with `def` โ€” that word is the same in Ruby.", + "better": "Your `def` intuition transfers directly.", + "example": "def greet(name): ... <-> def greet(name) ... end" + } + }, + { + "concept": "string-interpolation", + "from": { "language": "python", "surface": "f\"Hi {name}\"" }, + "to": { "language": "ruby", "surface": "\"Hi #{name}\"" }, + "kind": "cognate", + "residue": { + "shape": "none", + "note": "Same intent (interpolate a value into a string); Ruby uses `#{}` and needs no `f` prefix." + }, + "strata": [ + { "stratum": "surface", "holds": true }, + { "stratum": "intention", "holds": true } + ], + "narrative": { + "celebrate": "f-strings are second nature to you โ€” interpolation carries straight over.", + "better": "Drop the `f` and write `#{}` instead of `{}`.", + "example": "f\"Hi {name}\" <-> \"Hi #{name}\"" + } + }, + { + "concept": "empty-collection-truthiness", + "from": { "language": "python", "surface": "if items: # empty list is falsy" }, + "to": { "language": "ruby", "surface": "if items # empty array is TRUTHY" }, + "kind": "false-friend", + "residue": { + "shape": "lossy", + "note": "In Ruby only `nil` and `false` are falsy; `[]`, `\"\"`, and `0` are all truthy. The Python habit of testing a collection for emptiness via truthiness silently breaks." + }, + "strata": [ + { "stratum": "surface", "holds": true }, + { "stratum": "intention", "holds": false } + ], + "narrative": { + "celebrate": "You've leaned on truthiness to check for empty/zero for years โ€” that's a real, useful instinct.", + "safety": "In Ruby that instinct misfires: `if items` is true even when empty. Test emptiness explicitly.", + "example": "Python: if items: -> Ruby: if items.any? (or: unless items.empty?)" + } + }, + { + "concept": "map-over-collection", + "from": { "language": "python", "surface": "[f(x) for x in xs]" }, + "to": { "language": "ruby", "surface": "xs.map { |x| f(x) }" }, + "kind": "alien-realization", + "residue": { + "shape": "lossy", + "note": "Same intention (transform each element) realised through different machinery: a comprehension expression vs a method taking a block." + }, + "strata": [ + { "stratum": "intention", "holds": true }, + { "stratum": "structure", "holds": false } + ], + "narrative": { + "celebrate": "You think in comprehensions โ€” that mapping intent is exactly right.", + "better": "Ruby spells it as a block passed to `map`; once it clicks, blocks are everywhere.", + "example": "[f(x) for x in xs] -> xs.map { |x| f(x) }" + } + }, + { + "concept": "symbol", + "from": { "language": "python", "surface": "(no analog)" }, + "to": { "language": "ruby", "surface": ":status" }, + "kind": "novel", + "residue": { + "shape": "absent-source", + "note": "Python has no symbol type. The nearest habits โ€” interned strings, enum members, dict keys โ€” are not the same thing; learn the symbol fresh." + }, + "strata": [ + { "stratum": "intention", "holds": false } + ], + "narrative": { + "celebrate": "Coming from Python you have great instincts for dict keys and enums.", + "safety": "A Ruby symbol (`:status`) is its own immutable, interned identifier โ€” not a string. Learn it as a new thing rather than mapping it onto strings.", + "example": "{ status: :active } # :active is a symbol, not \"active\"" + } + }, + { + "concept": "explicit-self-parameter", + "from": { "language": "python", "surface": "def m(self):\n ..." }, + "to": { "language": "ruby", "surface": "def m\n ...\nend" }, + "kind": "vanished", + "residue": { + "shape": "absent-target", + "note": "Ruby has no explicit `self` parameter; the receiver is implicit. The Python habit of writing `self` first in every method has nowhere to go โ€” re-route it." + }, + "strata": [ + { "stratum": "intention", "holds": false } + ], + "narrative": { + "celebrate": "Writing `self` first is muscle memory from Python โ€” totally reasonable.", + "safety": "In Ruby that parameter vanishes: methods take only their real arguments, and `self` is implicit (use it bare when you need the receiver).", + "example": "Python: def m(self): ... -> Ruby: def m ... end" + } + } + ] +} diff --git a/deno.json b/deno.json index 4c6a34b..f8d740c 100644 --- a/deno.json +++ b/deno.json @@ -10,7 +10,8 @@ "gui": "deno run --allow-read --allow-net gui/server.js", "lint": "deno lint", "fmt": "deno fmt", - "test": "deno run --allow-read test/run_all.js", + "test": "deno run --allow-read test/run_all.js && deno run --allow-read test/gui_smoke_test.js", + "test:gui": "deno run --allow-read test/gui_smoke_test.js", "pre-commit": "deno task lint && deno task fmt --check" }, "fmt": { @@ -20,7 +21,7 @@ "semiColons": false, "singleQuote": false, "proseWrap": "preserve", - "include": ["bin/", "src/cartridges.js", "test/run_all.js", "deno.json"] + "include": ["bin/", "src/cartridges.js", "test/", "deno.json"] }, "lint": { "rules": { diff --git a/gui/server.js b/gui/server.js index b8f32ce..016c9a5 100644 --- a/gui/server.js +++ b/gui/server.js @@ -74,38 +74,45 @@ async function handler(req) { } } -const requestedPort = Number(argValue("--port", envPort() ?? "8765")); -const hostname = argValue("--host", "127.0.0.1"); -let server; -let port = requestedPort; -for (let attempt = 0; attempt < 20; attempt += 1) { - try { - port = requestedPort + attempt; - server = Deno.serve({ hostname, port, onListen: () => {} }, handler); - break; - } catch (err) { - if (String(err.message ?? err).includes("Address already in use")) continue; - throw err; +// Exported for in-process testing (test/gui_smoke_test.js). The listener below +// starts only when this file is run directly, not when imported. +export { handler, loadCartridges }; + +// --- server startup (only when run directly) -------------------------------- +if (import.meta.main) { + const requestedPort = Number(argValue("--port", envPort() ?? "8765")); + const hostname = argValue("--host", "127.0.0.1"); + let server; + let port = requestedPort; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + port = requestedPort + attempt; + server = Deno.serve({ hostname, port, onListen: () => {} }, handler); + break; + } catch (err) { + if (String(err.message ?? err).includes("Address already in use")) continue; + throw err; + } + } + if (!server) { + throw new Error(`No free port from ${requestedPort} to ${requestedPort + 19}`); } -} -if (!server) { - throw new Error(`No free port from ${requestedPort} to ${requestedPort + 19}`); -} -const addr = `http://${hostname}:${port}/`; -console.log(`Correspondence workspace: ${addr}`); + const addr = `http://${hostname}:${port}/`; + console.log(`Correspondence workspace: ${addr}`); -if (Deno.args.includes("--open")) { - setTimeout(() => { - for (const cmd of [["sensible-browser", addr], ["gio", "open", addr], ["xdg-open", addr]]) { - try { - new Deno.Command(cmd[0], { args: cmd.slice(1), stdout: "null", stderr: "null" }).spawn(); - return; - } catch { - // try the next opener + if (Deno.args.includes("--open")) { + setTimeout(() => { + for (const cmd of [["sensible-browser", addr], ["gio", "open", addr], ["xdg-open", addr]]) { + try { + new Deno.Command(cmd[0], { args: cmd.slice(1), stdout: "null", stderr: "null" }).spawn(); + return; + } catch { + // try the next opener + } } - } - }, 250); -} + }, 250); + } -await server.finished; + await server.finished; +} diff --git a/test/gui_smoke_test.js b/test/gui_smoke_test.js new file mode 100644 index 0000000..904854a --- /dev/null +++ b/test/gui_smoke_test.js @@ -0,0 +1,86 @@ +#!/usr/bin/env -S deno run --allow-read +// SPDX-License-Identifier: MPL-2.0 +// GUI smoke test โ€” exercises the workspace server's request handler in-process +// (no socket, no network): the static shell, the client bundle, and the +// cartridge API. gui/server.js exports `handler` and only opens a listener under +// import.meta.main, so importing it here is side-effect-free. +// +// deno run --allow-read test/gui_smoke_test.js +// deno task test / just test-gui + +import { handler } from "../gui/server.js" + +const KINDS = [ + "cognate", + "false-friend", + "antonym", + "alien-realization", + "novel", + "vanished", +] + +let passed = 0 +let failed = 0 +function check(label, cond) { + if (cond) { + passed++ + } else { + failed++ + console.error(` โœ— ${label}`) + } +} + +function get(path) { + return handler(new Request(`http://localhost${path}`)) +} + +console.log("Nextgen Languages Evangeliser โ€” GUI smoke test\n") + +// 1. the static shell +{ + const res = await get("/") + check("/ -> 200", res.status === 200) + check("/ is html", (res.headers.get("content-type") ?? "").includes("text/html")) + const html = await res.text() + check("/ has the workspace title", html.includes("Correspondence Workspace")) + for (const id of ["index", "paneForms", "paneClassify"]) { + check(`/ has #${id} pane`, html.includes(`id="${id}"`)) + } + check("/ loads /app.js", html.includes("/app.js")) +} + +// 2. the client bundle +{ + const res = await get("/app.js") + check("/app.js -> 200", res.status === 200) + check("/app.js is javascript", (res.headers.get("content-type") ?? "").includes("javascript")) + check("/app.js carries an SPDX header", (await res.text()).includes("SPDX-License-Identifier")) +} + +// 3. the cartridge API +{ + const res = await get("/api/cartridges") + check("/api/cartridges -> 200", res.status === 200) + const body = await res.json() + check("api: cartridges is an array", Array.isArray(body.cartridges)) + const transitions = (body.cartridges ?? []).flatMap((c) => c.transitions ?? []) + check("api: at least one correspondence", transitions.length >= 1) + for (const t of transitions) { + check(`api: '${t.concept}' has a valid kind`, KINDS.includes(t.kind)) + check(`api: '${t.concept}' has from+to`, !!t.from?.language && !!t.to?.language) + } +} + +// 4. unknown route is a clean 404 +{ + const res = await get("/does-not-exist") + check("/does-not-exist -> 404", res.status === 404) + await res.body?.cancel() +} + +console.log(`\n${passed} passed, ${failed} failed`) +if (failed) { + console.error("\nโŒ GUI smoke test failed") + Deno.exit(1) +} +console.log("โœ… GUI smoke test passed")