@hyperfixi/testing-framework 2.11.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +120 -0
- package/package.json +8 -8
- package/src/multilingual/bare-render-fidelity.test.ts +100 -0
- package/src/multilingual/bare-render-fidelity.ts +105 -0
- package/src/multilingual/fidelity.ts +1 -0
- package/src/multilingual/render-fidelity.test.ts +126 -0
- package/src/multilingual/render-fidelity.ts +204 -0
- package/src/multilingual/shipped-sources-validity.ts +43 -4
- package/src/vocab/batch3-roundtrip.test.ts +30 -48
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,126 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.0.0] - 2026-09-03
|
|
11
|
+
|
|
12
|
+
Full notes: [GitHub Releases](https://github.com/codetalcott/hyperfixi/releases/tag/v3.0.0).
|
|
13
|
+
|
|
14
|
+
One major, two themes. `@lokascript/i18n`'s grammar transformer is gone,
|
|
15
|
+
because `@lokascript/semantic` overtook it on every row of the translation
|
|
16
|
+
corpus (#973–#1001). And the engine-migration plan
|
|
17
|
+
(`docs-internal/ENGINE_MIGRATION_PLAN.md`) is complete: its last arc deleted
|
|
18
|
+
the exported dead code it had been carrying and collapsed the browser bundle
|
|
19
|
+
lineup to two names (#1099–#1105).
|
|
20
|
+
|
|
21
|
+
### ⚠ BREAKING
|
|
22
|
+
|
|
23
|
+
- **`@lokascript/i18n` no longer translates** (#1001). `translate()`, `toLocale()`,
|
|
24
|
+
`toEnglish()`, `GrammarTransformer` and `createTransformer` are deleted; the
|
|
25
|
+
package is per-language vocabulary and grammar profiles. Migrate to
|
|
26
|
+
`import { translate } from '@lokascript/semantic'` (same signature; it throws
|
|
27
|
+
on unparseable input where the transformer returned nonsense). The classic-i18n
|
|
28
|
+
browser bundle, `@hyperscript-tools/i18n` and `@lokascript/types-browser` drop
|
|
29
|
+
the same members (#998, #999); the patterns corpus writer is semantic-only (#1000).
|
|
30
|
+
- **The prebuilt browser bundles are two names** (#1105): `hyperfixi-hx.js`
|
|
31
|
+
(small: hybrid parser, blocks, expressions, htmx v1/v2 attributes) and
|
|
32
|
+
`hyperfixi.js` (everything); `hyperfixi-hx-v4.js` and
|
|
33
|
+
`hyperfixi-multilingual.js` stay as separate products. `hyperfixi-lite.js`,
|
|
34
|
+
`-lite-plus.js`, `-minimal.js`, `-standard.js` and their `./browser/*`
|
|
35
|
+
exports are gone — use `hyperfixi-hx.js`, `hyperfixi.js`, or the Vite
|
|
36
|
+
plugin, which picks the tier itself. `./browser/hybrid-complete` remains as
|
|
37
|
+
the plugin's internal fallback.
|
|
38
|
+
- **Exported dead code deleted from `@hyperfixi/core`** — none had a
|
|
39
|
+
production caller: the six `@deprecated` `features/` families and the
|
|
40
|
+
modular bundle's `features` namespace (#1099); `Lexer`/`Tokens` (#1100 —
|
|
41
|
+
use `tokenize`); the `unified-types` `Validator`, the `types.d.ts` shim and
|
|
42
|
+
`registry/multilingual` (#1101); the `async` command (#1102 — it was
|
|
43
|
+
unreachable from parsed hyperscript; the manifest is 58 commands);
|
|
44
|
+
`ContextProviderRegistry`, the context-provider `Proxy`, the registry's
|
|
45
|
+
`context` slot and the plugin `contextProviders` field (#1104 — set
|
|
46
|
+
request-scoped values as `context.locals`).
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **A small bundle fails loudly on a command it lacks and names `hyperfixi.js`**
|
|
51
|
+
(#1103). The hybrid parser had been dropping an unrecognised word silently;
|
|
52
|
+
making it loud also exposed and fixed three silent mis-parses in the hybrid
|
|
53
|
+
bundles: unquoted `fetch /api/data` fetched `/`, `send custom:event` sent
|
|
54
|
+
`custom` to `me`, and `repeat forever` ran zero iterations.
|
|
55
|
+
- **English→foreign rendering is gated** (#931–#972, #953): 3105/3105 corpus
|
|
56
|
+
renders parse on the canonical engine; the bare (handler-less) surface has its
|
|
57
|
+
own gate. `as JSON` stays part of the value (#991); a flattened loop header
|
|
58
|
+
closes (#992).
|
|
59
|
+
- Plugins report their real versions (#926); `analyze_content` is unshadowed in
|
|
60
|
+
the MCP server (#927); four dependency advisories resolved; the `domain-flow`
|
|
61
|
+
→ `server-bridge` route contract is pinned (#1000s).
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- `hyperfixi.js` and `hyperfixi-hx-v4.js` carry the 24-language render
|
|
66
|
+
vocabulary (~19 KB gzip each) so `translate` works in the browser (#931).
|
|
67
|
+
- Deprecated `@lokascript/domain-*` references repoint to `@lokascript/domains`.
|
|
68
|
+
|
|
69
|
+
## [2.11.1] - 2026-08-25
|
|
70
|
+
|
|
71
|
+
Patch release. Fixes the release workflow itself: the build now runs **after**
|
|
72
|
+
the version sync, so a published tarball carries its own version rather than
|
|
73
|
+
the previous one, and `version.ts` is committed (#925). Also lands the
|
|
74
|
+
agent-loop A/B benchmark with an isolated generator (#924).
|
|
75
|
+
|
|
76
|
+
## [2.11.0] - 2026-08-24
|
|
77
|
+
|
|
78
|
+
Full notes: [GitHub Releases](https://github.com/codetalcott/hyperfixi/releases/tag/v2.11.0).
|
|
79
|
+
|
|
80
|
+
### Changed
|
|
81
|
+
|
|
82
|
+
- **The domain-DSL family moved out** (#909). Twelve packages left this
|
|
83
|
+
monorepo: the ten publishable `@lokascript/domain-*` packages (bdd,
|
|
84
|
+
behaviorspec, config, flow, jsx, learn, llm, sql, todo, voice) plus the
|
|
85
|
+
private `domain-toolkit` and `mcp-multilingual-intent`. They now ship as
|
|
86
|
+
**`@lokascript/domains`**, one subpath export per domain, from the
|
|
87
|
+
[lokascript-domains](https://github.com/codetalcott/lokascript-domains)
|
|
88
|
+
repository.
|
|
89
|
+
|
|
90
|
+
All ten published names are **deprecated on npm**, each naming its
|
|
91
|
+
replacement subpath (`@lokascript/domain-sql` → "import from
|
|
92
|
+
`@lokascript/domains/sql`"). Existing installs keep working — deprecation is
|
|
93
|
+
a warning, not a removal — they simply stop receiving updates. The
|
|
94
|
+
pre-deletion tree is preserved at the `moved/domain-family` tag.
|
|
95
|
+
|
|
96
|
+
_Migration:_ replace nine dependencies with one, and change
|
|
97
|
+
`from '@lokascript/domain-x'` to `from '@lokascript/domains/x'`. The root
|
|
98
|
+
entry absorbed `domain-config` and exports `createDomainRegistry`,
|
|
99
|
+
`registerAllDomains` and `DOMAIN_PRIORITY`.
|
|
100
|
+
|
|
101
|
+
### Added
|
|
102
|
+
|
|
103
|
+
- **"Show in My Language"** — LSP request plus VSCode command (#921).
|
|
104
|
+
- **Verified-translation badge** in the compilation service (#920).
|
|
105
|
+
- **`scoreFidelity` / `@lokascript/semantic/fidelity`** (#919).
|
|
106
|
+
- **Inert-shape warnings** (#918) and surfaced unconsumed-input warnings (#916)
|
|
107
|
+
in the compilation service.
|
|
108
|
+
- **The agent-loop benchmark** and the silent-failure finding it produced
|
|
109
|
+
(#915), and the MCP server's agent-era roadmap (#914).
|
|
110
|
+
- **Element-collection write-back** for hypermedia tables, with gallery
|
|
111
|
+
examples and four system fixes (#904).
|
|
112
|
+
- **The comprehensive Playwright tier now runs in CI** (#908) alongside
|
|
113
|
+
`quick`. It had never run: 122 specs, hiding four real bugs — a detached
|
|
114
|
+
`startViewTransition`, untracked reads of unset globals, concurrent effects
|
|
115
|
+
clobbering dependency capture, and `put` stringifying DocumentFragments
|
|
116
|
+
(#905).
|
|
117
|
+
|
|
118
|
+
### Fixed
|
|
119
|
+
|
|
120
|
+
- **The `hyperscript-adapter` review arc** (#895–#902): whole-string
|
|
121
|
+
translation first, taking canonical validity from 2849/3105 to **3105/3105**
|
|
122
|
+
(#899); a host-parser validity gate so an invalid render falls back to the
|
|
123
|
+
author's text (#900); the dead split-statement fallback deleted after
|
|
124
|
+
measuring 0 outputs (#901); and the slim divergence set burned 6 → 1 via
|
|
125
|
+
schema marker data, repairing 39 body-dropping corpus rows (#902).
|
|
126
|
+
- **`qu take.recipient`** — the R1 tail's last `take` row (#910).
|
|
127
|
+
- **The release workflows** no longer reference the moved-out domain family
|
|
128
|
+
(#922).
|
|
129
|
+
|
|
10
130
|
## [2.10.0] - 2026-08-01
|
|
11
131
|
|
|
12
132
|
Full notes: [GitHub Releases](https://github.com/codetalcott/hyperfixi/releases/tag/v2.10.0).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperfixi/testing-framework",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Cross-platform behavior testing suite for LokaScript applications",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"analyze-failures": "tsx src/multilingual/tools/analyze-failures.ts",
|
|
41
41
|
"typecheck": "tsc --noEmit",
|
|
42
42
|
"test:check": "VITEST_QUIET=1 bash ../../scripts/vitest-run.sh --reporter=dot",
|
|
43
|
-
"test:canonical": "FOREIGN_CANONICAL_VALIDITY=1 VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/canonical-validity.test.ts src/multilingual/foreign-canonical-validity.test.ts",
|
|
43
|
+
"test:canonical": "FOREIGN_CANONICAL_VALIDITY=1 VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/canonical-validity.test.ts src/multilingual/foreign-canonical-validity.test.ts src/multilingual/render-fidelity.test.ts src/multilingual/bare-render-fidelity.test.ts",
|
|
44
44
|
"test:shipped-sources": "VITEST_TIMEOUT=300 bash ../../scripts/vitest-run.sh --reporter=dot src/multilingual/shipped-sources-validity.test.ts"
|
|
45
45
|
},
|
|
46
46
|
"keywords": [
|
|
@@ -57,17 +57,17 @@
|
|
|
57
57
|
"author": "LokaScript Contributors",
|
|
58
58
|
"license": "MIT",
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@hyperfixi/core": "^
|
|
61
|
-
"@hyperfixi/patterns-reference": "^
|
|
62
|
-
"@lokascript/compilation-service": "^
|
|
63
|
-
"@lokascript/i18n": "^
|
|
64
|
-
"@lokascript/semantic": "^
|
|
60
|
+
"@hyperfixi/core": "^3.0.0",
|
|
61
|
+
"@hyperfixi/patterns-reference": "^3.0.0",
|
|
62
|
+
"@lokascript/compilation-service": "^3.0.0",
|
|
63
|
+
"@lokascript/i18n": "^3.0.0",
|
|
64
|
+
"@lokascript/semantic": "^3.0.0",
|
|
65
65
|
"diff": "^8.0.3",
|
|
66
66
|
"esbuild": "^0.28.0",
|
|
67
67
|
"happy-dom": "^20.10.6",
|
|
68
68
|
"jsdom": "^30.0.0",
|
|
69
69
|
"playwright": "^1.40.0",
|
|
70
|
-
"puppeteer": "^
|
|
70
|
+
"puppeteer": "^25.9.0",
|
|
71
71
|
"tsx": "^4.23.1",
|
|
72
72
|
"url-parse": "^1.5.10",
|
|
73
73
|
"vite": "^8.1.4"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BARE-surface en→foreign render-fidelity ratchet (see bare-render-fidelity.ts
|
|
3
|
+
* for why this exists and why it is not redundant with the wrapped gate).
|
|
4
|
+
*
|
|
5
|
+
* Identical assertions to `render-fidelity.test.ts`, over handler-STRIPPED
|
|
6
|
+
* corpus bodies. The allowlist is seeded at the level measured when the gate
|
|
7
|
+
* landed, so it starts green; it is a record of what is known-broken, not a
|
|
8
|
+
* target, and completing a fix means deleting entries.
|
|
9
|
+
*
|
|
10
|
+
* Needs FOREIGN_CANONICAL_VALIDITY=1 and a freshly populated patterns.db, the
|
|
11
|
+
* same contract the two sibling gates carry.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
17
|
+
import { checkBareRenderFidelity } from './bare-render-fidelity';
|
|
18
|
+
import { groupFailuresByPattern, type RenderFidelityResult } from './render-fidelity';
|
|
19
|
+
|
|
20
|
+
interface AllowlistDoc {
|
|
21
|
+
checked: number;
|
|
22
|
+
clean: number;
|
|
23
|
+
cleanPct: number;
|
|
24
|
+
allowedFailures: Record<string, string[]>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const baselinePath = path.resolve(
|
|
28
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
29
|
+
'../../baselines/bare-render-fidelity.json'
|
|
30
|
+
);
|
|
31
|
+
const allowlist = JSON.parse(readFileSync(baselinePath, 'utf8')) as AllowlistDoc;
|
|
32
|
+
const key = (id: string, language: string) => `${id} ${language}`;
|
|
33
|
+
const allowed = new Set(
|
|
34
|
+
Object.entries(allowlist.allowedFailures).flatMap(([id, langs]) => langs.map(l => key(id, l)))
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const DB_FRESHLY_POPULATED = process.env.FOREIGN_CANONICAL_VALIDITY === '1';
|
|
38
|
+
|
|
39
|
+
describe.skipIf(!DB_FRESHLY_POPULATED)('bare-surface english→foreign render-fidelity gate', () => {
|
|
40
|
+
let result: RenderFidelityResult;
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
result = await checkBareRenderFidelity();
|
|
44
|
+
}, 300_000);
|
|
45
|
+
|
|
46
|
+
it('scores a non-empty bare corpus in every language (sanity: guards a false green)', () => {
|
|
47
|
+
// A derivation that stopped finding handler bodies — a changed handler head
|
|
48
|
+
// shape, a broken strip — would otherwise report zero failures and pass.
|
|
49
|
+
expect(result.checked).toBeGreaterThan(2500);
|
|
50
|
+
expect(result.clean).toBeGreaterThan(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('does not regress the measured clean rate', () => {
|
|
54
|
+
const cleanPct = (100 * result.clean) / result.checked;
|
|
55
|
+
expect(
|
|
56
|
+
cleanPct,
|
|
57
|
+
`clean rate fell to ${cleanPct.toFixed(2)}% from the committed ${allowlist.cleanPct}%`
|
|
58
|
+
).toBeGreaterThanOrEqual(allowlist.cleanPct - 0.01);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('produces no NEW failing (pattern, language) bare render outside the allowlist', () => {
|
|
62
|
+
const unexpected = result.failures.filter(f => !allowed.has(key(f.id, f.language)));
|
|
63
|
+
expect(
|
|
64
|
+
unexpected,
|
|
65
|
+
unexpected.length
|
|
66
|
+
? `\nNew BARE render-fidelity failures (fix it, or allowlist the pair):\n` +
|
|
67
|
+
unexpected
|
|
68
|
+
.map(
|
|
69
|
+
f =>
|
|
70
|
+
` [${f.id}] (${f.language})\n en: ${f.english.split('\n')[0]}\n` +
|
|
71
|
+
` out: ${f.rendered.split('\n')[0] || '<render threw>'}\n` +
|
|
72
|
+
` lost: ${[...f.missingActions.map(a => `action:${a}`), ...f.missingRoles].join(', ')}`
|
|
73
|
+
)
|
|
74
|
+
.join('\n')
|
|
75
|
+
: ''
|
|
76
|
+
).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('has no stale allowlist pairs (a now-passing pair must be removed so the list ratchets down)', () => {
|
|
80
|
+
const stillFailing = new Set(result.failures.map(f => key(f.id, f.language)));
|
|
81
|
+
const stale: string[] = [];
|
|
82
|
+
for (const [id, langs] of Object.entries(allowlist.allowedFailures)) {
|
|
83
|
+
for (const language of langs) {
|
|
84
|
+
if (!stillFailing.has(key(id, language))) stale.push(`${id}/${language}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
expect(
|
|
88
|
+
stale,
|
|
89
|
+
stale.length
|
|
90
|
+
? `\nThese allowlisted pairs now render faithfully bare — prune them from ` +
|
|
91
|
+
`baselines/bare-render-fidelity.json (regenerate with ` +
|
|
92
|
+
`tools/regen-bare-render-fidelity-baseline.ts):\n ${stale.join('\n ')}`
|
|
93
|
+
: ''
|
|
94
|
+
).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('keeps the committed allowlist grouping in sync with the live failure set', () => {
|
|
98
|
+
expect(groupFailuresByPattern(result.failures)).toEqual(allowlist.allowedFailures);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BARE-surface en→foreign render-fidelity gate.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* ---------------
|
|
6
|
+
* Every corpus row that exercises a command wraps it in an event handler, so
|
|
7
|
+
* the eleven multilingual signals, `render-fidelity`, and 8,800 unit tests are
|
|
8
|
+
* all blind to the PLAIN form of the same command. That blindness is not
|
|
9
|
+
* theoretical: measured 2026-08-27, `hyperfixi.translate('toggle .active on
|
|
10
|
+
* #panel', 'en', 'bn')` produced a surface that did not parse back AT ALL — the
|
|
11
|
+
* plainest two-role toggle there is — and neither did tl/vi's bare `set the
|
|
12
|
+
* *background-color of #theme to "#ff6600"`. Both were pre-existing, both were
|
|
13
|
+
* found by hand, and nothing in CI could have reported either.
|
|
14
|
+
*
|
|
15
|
+
* It matters because the bare form is a first-class public surface: MCP
|
|
16
|
+
* `translate_code`, `hyperfixi.translate`/`getAllTranslations`, core's
|
|
17
|
+
* `MultilingualHyperscript` and the VS Code "Show in my language" badge are all
|
|
18
|
+
* routinely handed a single command with no handler around it.
|
|
19
|
+
*
|
|
20
|
+
* WHAT IT ASSERTS
|
|
21
|
+
* ---------------
|
|
22
|
+
* Exactly what `render-fidelity` asserts — render the English into each
|
|
23
|
+
* language, parse it back, require that no action and no role went missing —
|
|
24
|
+
* but over the HANDLER-STRIPPED body of each corpus pattern instead of the
|
|
25
|
+
* whole row. Same ratchet shape, same allowlist contract, and it delegates to
|
|
26
|
+
* `checkRenderFidelity` so the two can never drift in how they score.
|
|
27
|
+
*
|
|
28
|
+
* NOT REDUNDANT. Of the 69 failing pairs this gate sees today, **37 are
|
|
29
|
+
* invisible to the wrapped gate** — they pass wrapped and fail bare, because a
|
|
30
|
+
* fused per-command handler pattern binds a role the standalone pattern misses.
|
|
31
|
+
* Four of the 37 do not parse back at all.
|
|
32
|
+
*
|
|
33
|
+
* DERIVED, NOT HAND-PICKED. The corpus supplies the constructs; there is no
|
|
34
|
+
* command list to maintain here. A hand-written one would drift exactly the way
|
|
35
|
+
* `scripts/test-check-all.sh` and the ci.yml job lists did.
|
|
36
|
+
*
|
|
37
|
+
* DB DEPENDENCY. Same as the wrapped gate: the SET of corpus rows depends on a
|
|
38
|
+
* fresh `populate`, so this runs only when the caller asserts one.
|
|
39
|
+
*/
|
|
40
|
+
import { getAllPatterns } from '@hyperfixi/patterns-reference';
|
|
41
|
+
import { parseSemantic } from '@lokascript/semantic';
|
|
42
|
+
import { checkRenderFidelity, type RenderFidelityResult } from './render-fidelity';
|
|
43
|
+
|
|
44
|
+
/** A corpus row reduced to the body of its event handler. */
|
|
45
|
+
export interface BareBody {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly rawCode: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Strip a leading `on <event> [from <source>] ` handler head.
|
|
52
|
+
*
|
|
53
|
+
* Deliberately only the simple head: a row whose handler carries modifiers this
|
|
54
|
+
* does not recognize keeps its head, still parses as a handler, and is dropped
|
|
55
|
+
* by the guard in {@link deriveBareBodies} rather than being scored wrongly.
|
|
56
|
+
*/
|
|
57
|
+
function stripHandlerHead(english: string): string | null {
|
|
58
|
+
const match = /^on\s+\S+(\s+from\s+\S+)?\s+/.exec(english);
|
|
59
|
+
if (!match) return null;
|
|
60
|
+
const body = english.slice(match[0].length).trim();
|
|
61
|
+
return body.length > 0 ? body : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Reduce the corpus to the handler bodies that are meaningful bare.
|
|
66
|
+
*
|
|
67
|
+
* Three exclusions, each because the row has no bare surface to score:
|
|
68
|
+
* - no handler head (a `behavior`/`socket`/`eventsource` block, or already bare);
|
|
69
|
+
* - the stripped body does not parse as English at all;
|
|
70
|
+
* - the stripped body STILL parses as an event handler. A block-shaped body
|
|
71
|
+
* (`if … end`) re-anchors as one, which leaks a phantom `on` action into the
|
|
72
|
+
* reference that then "goes missing" on the round trip for reasons that have
|
|
73
|
+
* nothing to do with the bare surface. Measured: 7 rows, and they accounted
|
|
74
|
+
* for 24 of the 61 apparent bare-only failures before the guard existed.
|
|
75
|
+
*/
|
|
76
|
+
export function deriveBareBodies(
|
|
77
|
+
patterns: ReadonlyArray<{ id: string; rawCode: string }>
|
|
78
|
+
): BareBody[] {
|
|
79
|
+
const bodies: BareBody[] = [];
|
|
80
|
+
for (const pattern of patterns) {
|
|
81
|
+
const body = stripHandlerHead(pattern.rawCode);
|
|
82
|
+
if (!body) continue;
|
|
83
|
+
let reference;
|
|
84
|
+
try {
|
|
85
|
+
reference = parseSemantic(body, 'en')?.node ?? null;
|
|
86
|
+
} catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!reference) continue;
|
|
90
|
+
if ((reference as { action?: string }).action === 'on') continue;
|
|
91
|
+
bodies.push({ id: pattern.id, rawCode: body });
|
|
92
|
+
}
|
|
93
|
+
return bodies;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Score every corpus handler BODY, bare, in every language. */
|
|
97
|
+
export async function checkBareRenderFidelity(opts?: {
|
|
98
|
+
languages?: readonly string[];
|
|
99
|
+
}): Promise<RenderFidelityResult> {
|
|
100
|
+
const patterns = await getAllPatterns({ limit: 1000 });
|
|
101
|
+
const bodies = deriveBareBodies(patterns);
|
|
102
|
+
return opts?.languages
|
|
103
|
+
? checkRenderFidelity({ languages: opts.languages, patterns: bodies })
|
|
104
|
+
: checkRenderFidelity({ patterns: bodies });
|
|
105
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* English→foreign render-fidelity ratchet (see render-fidelity.ts for why).
|
|
3
|
+
*
|
|
4
|
+
* Renders every corpus pattern's English source into each of the 23 languages,
|
|
5
|
+
* parses it back, and requires that no action and no role from the English
|
|
6
|
+
* reference went missing. Two ratchet assertions, at (pattern, language)
|
|
7
|
+
* granularity, mirroring the foreign→English gate:
|
|
8
|
+
* 1. no NEW failing pair appears outside the committed allowlist;
|
|
9
|
+
* 2. no allowlisted pair has silently started passing (stale entries must be
|
|
10
|
+
* removed, so the list only ever shrinks).
|
|
11
|
+
*
|
|
12
|
+
* The allowlist was seeded at 75.97% clean — the level measured when the gate
|
|
13
|
+
* landed — so it starts green. It is a record of what is known-broken, not a
|
|
14
|
+
* target: completing a renderer fix means deleting entries from it.
|
|
15
|
+
*
|
|
16
|
+
* DB DEPENDENCY — corrected 2026-08-26 after CI disagreed with local.
|
|
17
|
+
* The gate renders `rawCode`, and that text is stable, but the SET of rows is
|
|
18
|
+
* not: `populate` re-runs `discoverPatterns` and finds examples the committed
|
|
19
|
+
* (frozen) patterns.db lacks — 3588 pairs against a fresh DB versus 3542
|
|
20
|
+
* against the committed one, which moved the clean rate 75.89% vs 75.97% and
|
|
21
|
+
* failed this gate on its own first CI run. So it carries the same guard as the
|
|
22
|
+
* foreign gate: it runs only when the caller asserts a fresh populate, and the
|
|
23
|
+
* baseline is seeded from that state.
|
|
24
|
+
*
|
|
25
|
+
* Regenerate after an intentional renderer change with
|
|
26
|
+
* `npm run populate --prefix packages/patterns-reference` followed by
|
|
27
|
+
* `npx tsx tools/regen-render-fidelity-baseline.ts`, and commit the result.
|
|
28
|
+
*/
|
|
29
|
+
import { readFileSync } from 'node:fs';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
33
|
+
import {
|
|
34
|
+
checkRenderFidelity,
|
|
35
|
+
groupFailuresByPattern,
|
|
36
|
+
type RenderFidelityResult,
|
|
37
|
+
} from './render-fidelity';
|
|
38
|
+
|
|
39
|
+
interface AllowlistDoc {
|
|
40
|
+
checked: number;
|
|
41
|
+
clean: number;
|
|
42
|
+
cleanPct: number;
|
|
43
|
+
allowedFailures: Record<string, string[]>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const baselinePath = path.resolve(
|
|
47
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
48
|
+
'../../baselines/render-fidelity.json'
|
|
49
|
+
);
|
|
50
|
+
const allowlist = JSON.parse(readFileSync(baselinePath, 'utf8')) as AllowlistDoc;
|
|
51
|
+
const key = (id: string, language: string) => `${id} ${language}`;
|
|
52
|
+
const allowed = new Set(
|
|
53
|
+
Object.entries(allowlist.allowedFailures).flatMap(([id, langs]) => langs.map(l => key(id, l)))
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Same contract as the foreign gate: a plain `vitest run` on a stale checkout
|
|
57
|
+
// skips rather than reporting phantom drift. `npm run test:canonical` and the CI
|
|
58
|
+
// multilingual job both set this, after populating.
|
|
59
|
+
const DB_FRESHLY_POPULATED = process.env.FOREIGN_CANONICAL_VALIDITY === '1';
|
|
60
|
+
|
|
61
|
+
describe.skipIf(!DB_FRESHLY_POPULATED)('english→foreign render-fidelity gate', () => {
|
|
62
|
+
let result: RenderFidelityResult;
|
|
63
|
+
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
result = await checkRenderFidelity();
|
|
66
|
+
}, 300_000);
|
|
67
|
+
|
|
68
|
+
it('scores a non-empty corpus in every language (sanity: guards a false green)', () => {
|
|
69
|
+
// A corpus that failed to load would otherwise report zero failures and pass.
|
|
70
|
+
expect(result.checked).toBeGreaterThan(3000);
|
|
71
|
+
expect(result.clean).toBeGreaterThan(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('does not regress the measured clean rate', () => {
|
|
75
|
+
// The headline number, kept honest independently of the pair-level lists:
|
|
76
|
+
// it may rise freely, but a drop means a render got worse somewhere the
|
|
77
|
+
// per-pair assertions might net out to zero.
|
|
78
|
+
const cleanPct = (100 * result.clean) / result.checked;
|
|
79
|
+
expect(
|
|
80
|
+
cleanPct,
|
|
81
|
+
`clean rate fell to ${cleanPct.toFixed(2)}% from the committed ${allowlist.cleanPct}%`
|
|
82
|
+
).toBeGreaterThanOrEqual(allowlist.cleanPct - 0.01);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('produces no NEW failing (pattern, language) render outside the allowlist', () => {
|
|
86
|
+
const unexpected = result.failures.filter(f => !allowed.has(key(f.id, f.language)));
|
|
87
|
+
expect(
|
|
88
|
+
unexpected,
|
|
89
|
+
unexpected.length
|
|
90
|
+
? `\nNew render-fidelity failures (fix the renderer, or allowlist the pair):\n` +
|
|
91
|
+
unexpected
|
|
92
|
+
.map(
|
|
93
|
+
f =>
|
|
94
|
+
` [${f.id}] (${f.language})\n en: ${f.english.split('\n')[0]}\n` +
|
|
95
|
+
` out: ${f.rendered.split('\n')[0] || '<render threw>'}\n` +
|
|
96
|
+
` lost: ${[...f.missingActions.map(a => `action:${a}`), ...f.missingRoles].join(', ')}`
|
|
97
|
+
)
|
|
98
|
+
.join('\n')
|
|
99
|
+
: ''
|
|
100
|
+
).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('has no stale allowlist pairs (a now-passing pair must be removed so the list ratchets down)', () => {
|
|
104
|
+
const stillFailing = new Set(result.failures.map(f => key(f.id, f.language)));
|
|
105
|
+
const stale: string[] = [];
|
|
106
|
+
for (const [id, langs] of Object.entries(allowlist.allowedFailures)) {
|
|
107
|
+
for (const language of langs) {
|
|
108
|
+
if (!stillFailing.has(key(id, language))) stale.push(`${id}/${language}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
expect(
|
|
112
|
+
stale,
|
|
113
|
+
stale.length
|
|
114
|
+
? `\nThese allowlisted pairs now render faithfully — prune them from ` +
|
|
115
|
+
`baselines/render-fidelity.json (regenerate with ` +
|
|
116
|
+
`tools/regen-render-fidelity-baseline.ts):\n ${stale.join('\n ')}`
|
|
117
|
+
: ''
|
|
118
|
+
).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('keeps the committed allowlist grouping in sync with the live failure set', () => {
|
|
122
|
+
// Structural cross-check: redundant with the two assertions above, but it
|
|
123
|
+
// yields a single clear diff when regeneration is needed.
|
|
124
|
+
expect(groupFailuresByPattern(result.failures)).toEqual(allowlist.allowedFailures);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* English→foreign RENDER-fidelity gate.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* ---------------
|
|
6
|
+
* Nothing gated this direction. The multilingual ratchet scores the STORED
|
|
7
|
+
* `pattern_translations` rows, which @lokascript/i18n's GrammarTransformer
|
|
8
|
+
* writes — it never calls `render(node, L)` at all. `canonical-validity` is
|
|
9
|
+
* en→en and `foreign-canonical-validity` is foreign→en, both of which pass.
|
|
10
|
+
* So `render(parse(en), L)` — the function behind MCP `translate_code`,
|
|
11
|
+
* `hyperfixi.translate`, `getAllTranslations`, core's `MultilingualHyperscript`
|
|
12
|
+
* and the VS Code "Show in my language" badge — was measured by nothing.
|
|
13
|
+
*
|
|
14
|
+
* When it was finally measured (2026-08-26) it was 73.3% structurally clean
|
|
15
|
+
* against the English reference where the i18n-written corpus was 97.0%.
|
|
16
|
+
* `README.md`, `AGENTS.md` and the MCP server instructions all attach a
|
|
17
|
+
* structural-fidelity guarantee to exactly this direction.
|
|
18
|
+
*
|
|
19
|
+
* WHAT IT ASSERTS
|
|
20
|
+
* ---------------
|
|
21
|
+
* For every corpus pattern and every non-English language: render the English
|
|
22
|
+
* source into that language, parse it back, and require that no ACTION and no
|
|
23
|
+
* ROLE from the English reference went missing. Failures are recorded per
|
|
24
|
+
* (pattern, language) against a committed allowlist that may only shrink —
|
|
25
|
+
* the same ratchet shape as `foreign-canonical-validity`.
|
|
26
|
+
*
|
|
27
|
+
* The allowlist is seeded at the level measured when the gate landed, so it
|
|
28
|
+
* lands green and every later improvement is a deletion from it. It is a
|
|
29
|
+
* record of what is known-broken, not a target.
|
|
30
|
+
*
|
|
31
|
+
* DB DEPENDENCY. The rendered text comes from `rawCode`, which is stable, but
|
|
32
|
+
* the SET of rows is not: `populate` re-runs `discoverPatterns` and finds
|
|
33
|
+
* examples the committed (frozen) patterns.db lacks. Measured 2026-08-26:
|
|
34
|
+
* 3588 pairs against a fresh DB, 3542 against the committed one — enough to
|
|
35
|
+
* move the clean rate (75.89% vs 75.97%) and fail the ratchet spuriously. So
|
|
36
|
+
* this gate needs a freshly populated DB, exactly like the foreign one, and
|
|
37
|
+
* its test carries the matching guard.
|
|
38
|
+
*
|
|
39
|
+
* STRICT ROLE SIGNATURES. Scoring uses `collectRoleSignatureStrict`, which
|
|
40
|
+
* ignores roles the matcher injected as schema defaults. Without that, a
|
|
41
|
+
* render that drops `to me` and a parser that puts `destination: me` back are
|
|
42
|
+
* indistinguishable, and a role-dropping render scores as faithful. Both sides
|
|
43
|
+
* are filtered, so a role implicit in the reference and in the candidate
|
|
44
|
+
* cancels out.
|
|
45
|
+
*/
|
|
46
|
+
import { getAllPatterns } from '@hyperfixi/patterns-reference';
|
|
47
|
+
import { parseSemantic, render, type SemanticNode } from '@lokascript/semantic';
|
|
48
|
+
// Via the local shim, the path every other gate in this directory uses.
|
|
49
|
+
import { collectActions, collectRoleSignatureStrict } from './fidelity';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The 23 non-English corpus languages. Mirrors `FOREIGN_LANGUAGES` in
|
|
53
|
+
* foreign-canonical-validity.ts; kept as its own list because this gate can run
|
|
54
|
+
* over a language the corpus has no authored translations for.
|
|
55
|
+
*/
|
|
56
|
+
export const RENDER_LANGUAGES = [
|
|
57
|
+
'ar',
|
|
58
|
+
'bn',
|
|
59
|
+
'de',
|
|
60
|
+
'es',
|
|
61
|
+
'fr',
|
|
62
|
+
'he',
|
|
63
|
+
'hi',
|
|
64
|
+
'id',
|
|
65
|
+
'it',
|
|
66
|
+
'ja',
|
|
67
|
+
'ko',
|
|
68
|
+
'ms',
|
|
69
|
+
'pl',
|
|
70
|
+
'pt',
|
|
71
|
+
'qu',
|
|
72
|
+
'ru',
|
|
73
|
+
'sw',
|
|
74
|
+
'th',
|
|
75
|
+
'tl',
|
|
76
|
+
'tr',
|
|
77
|
+
'uk',
|
|
78
|
+
'vi',
|
|
79
|
+
'zh',
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
82
|
+
export interface RenderFidelityFailure {
|
|
83
|
+
readonly id: string;
|
|
84
|
+
readonly language: string;
|
|
85
|
+
readonly english: string;
|
|
86
|
+
readonly rendered: string;
|
|
87
|
+
/** Actions present in the English reference and absent after the round trip. */
|
|
88
|
+
readonly missingActions: readonly string[];
|
|
89
|
+
/** `action.role:valueType` entries present in the reference and absent after. */
|
|
90
|
+
readonly missingRoles: readonly string[];
|
|
91
|
+
/** Set when the rendered surface could not be parsed back at all. */
|
|
92
|
+
readonly unparseable?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface RenderFidelityResult {
|
|
96
|
+
readonly checked: number;
|
|
97
|
+
readonly clean: number;
|
|
98
|
+
readonly failures: readonly RenderFidelityFailure[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function safeParse(code: string, language: string): SemanticNode | null {
|
|
102
|
+
try {
|
|
103
|
+
return parseSemantic(code, language)?.node ?? null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Render every corpus pattern into every language and score the round trip.
|
|
111
|
+
*
|
|
112
|
+
* A pattern whose English source does not itself parse is skipped rather than
|
|
113
|
+
* counted as a failure — it has no reference to compare against, and that is a
|
|
114
|
+
* parser question this gate does not ask.
|
|
115
|
+
*/
|
|
116
|
+
export async function checkRenderFidelity(opts?: {
|
|
117
|
+
languages?: readonly string[];
|
|
118
|
+
patterns?: ReadonlyArray<{ id: string; rawCode: string }>;
|
|
119
|
+
}): Promise<RenderFidelityResult> {
|
|
120
|
+
const languages = opts?.languages ?? RENDER_LANGUAGES;
|
|
121
|
+
const patterns = opts?.patterns ?? (await getAllPatterns({ limit: 1000 }));
|
|
122
|
+
|
|
123
|
+
const failures: RenderFidelityFailure[] = [];
|
|
124
|
+
let checked = 0;
|
|
125
|
+
let clean = 0;
|
|
126
|
+
|
|
127
|
+
for (const pattern of patterns) {
|
|
128
|
+
// A pattern whose English source does not parse has no reference to compare
|
|
129
|
+
// against, so it is skipped rather than counted as a failure — that is a
|
|
130
|
+
// parser question, asked by other gates, not this one. This is also what
|
|
131
|
+
// excludes the non-translatable HTML-markup rows: they are not hyperscript,
|
|
132
|
+
// so they never parse, and there is no rendering decision to score.
|
|
133
|
+
const reference = safeParse(pattern.rawCode, 'en');
|
|
134
|
+
if (!reference) continue;
|
|
135
|
+
|
|
136
|
+
const refActions = collectActions(reference);
|
|
137
|
+
const refRoles = collectRoleSignatureStrict(reference);
|
|
138
|
+
|
|
139
|
+
for (const language of languages) {
|
|
140
|
+
checked++;
|
|
141
|
+
let rendered = '';
|
|
142
|
+
try {
|
|
143
|
+
rendered = render(reference, language);
|
|
144
|
+
} catch {
|
|
145
|
+
failures.push({
|
|
146
|
+
id: pattern.id,
|
|
147
|
+
language,
|
|
148
|
+
english: pattern.rawCode,
|
|
149
|
+
rendered: '',
|
|
150
|
+
missingActions: refActions,
|
|
151
|
+
missingRoles: refRoles,
|
|
152
|
+
unparseable: true,
|
|
153
|
+
});
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const roundTripped = safeParse(rendered, language);
|
|
158
|
+
if (!roundTripped) {
|
|
159
|
+
failures.push({
|
|
160
|
+
id: pattern.id,
|
|
161
|
+
language,
|
|
162
|
+
english: pattern.rawCode,
|
|
163
|
+
rendered,
|
|
164
|
+
missingActions: refActions,
|
|
165
|
+
missingRoles: refRoles,
|
|
166
|
+
unparseable: true,
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const gotActions = new Set(collectActions(roundTripped));
|
|
172
|
+
const gotRoles = new Set(collectRoleSignatureStrict(roundTripped));
|
|
173
|
+
const missingActions = refActions.filter(a => !gotActions.has(a));
|
|
174
|
+
const missingRoles = refRoles.filter(r => !gotRoles.has(r));
|
|
175
|
+
|
|
176
|
+
if (missingActions.length === 0 && missingRoles.length === 0) {
|
|
177
|
+
clean++;
|
|
178
|
+
} else {
|
|
179
|
+
failures.push({
|
|
180
|
+
id: pattern.id,
|
|
181
|
+
language,
|
|
182
|
+
english: pattern.rawCode,
|
|
183
|
+
rendered,
|
|
184
|
+
missingActions,
|
|
185
|
+
missingRoles,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { checked, clean, failures };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Group failures as `{ patternId: [language, …] }` — the committed allowlist shape. */
|
|
195
|
+
export function groupFailuresByPattern(
|
|
196
|
+
failures: readonly RenderFidelityFailure[]
|
|
197
|
+
): Record<string, string[]> {
|
|
198
|
+
const grouped: Record<string, string[]> = {};
|
|
199
|
+
for (const failure of failures) {
|
|
200
|
+
(grouped[failure.id] ??= []).push(failure.language);
|
|
201
|
+
}
|
|
202
|
+
for (const languages of Object.values(grouped)) languages.sort();
|
|
203
|
+
return Object.fromEntries(Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)));
|
|
204
|
+
}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
import fs from 'node:fs';
|
|
31
31
|
import path from 'node:path';
|
|
32
|
+
import { execFileSync } from 'node:child_process';
|
|
32
33
|
import { createHash } from 'node:crypto';
|
|
33
34
|
import { fileURLToPath } from 'node:url';
|
|
34
35
|
import { extractHyperscriptFromMarkup } from '@hyperfixi/patterns-reference';
|
|
@@ -95,7 +96,40 @@ function keyFor(file: string, source: string): string {
|
|
|
95
96
|
return `${file}::${createHash('sha1').update(source).digest('hex').slice(0, 10)}`;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
/**
|
|
100
|
+
* The files git actually tracks under `repoRoot`, as absolute paths.
|
|
101
|
+
*
|
|
102
|
+
* This gate walks the working TREE, and a working tree is not a clean checkout:
|
|
103
|
+
* `examples/vite-plugin-multilingual/` is GITIGNORED, so a local run saw a
|
|
104
|
+
* source CI could never see. That is unfixable from the allowlist — with an
|
|
105
|
+
* entry for it the gate failed in CI as a STALE entry, and without one it
|
|
106
|
+
* failed locally as a NEW finding. No allowlist state satisfies both, so the
|
|
107
|
+
* DENOMINATOR is what has to agree.
|
|
108
|
+
*
|
|
109
|
+
* The sibling `shipped-examples-execution` gate already derives its corpus this
|
|
110
|
+
* way, and its comment records that the lesson cost **#862** — it "failed on
|
|
111
|
+
* every clean checkout the first time CI ran it" for exactly this reason. This
|
|
112
|
+
* gate was simply never brought into line; it cost another CI round-trip on
|
|
113
|
+
* 2026-08-31 before anyone noticed the two disagreed.
|
|
114
|
+
*
|
|
115
|
+
* git being unavailable THROWS rather than falling back to the full tree, which
|
|
116
|
+
* is the sibling's convention and for its stated reason: a silent fallback
|
|
117
|
+
* would resurrect the drift this exists to kill.
|
|
118
|
+
*/
|
|
119
|
+
function trackedFiles(repoRoot: string): Set<string> {
|
|
120
|
+
const out = execFileSync('git', ['-C', repoRoot, 'ls-files', '-z'], {
|
|
121
|
+
encoding: 'utf8',
|
|
122
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
123
|
+
});
|
|
124
|
+
return new Set(
|
|
125
|
+
out
|
|
126
|
+
.split('\0')
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.map(rel => path.join(repoRoot, rel))
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function walk(dir: string, acc: string[] = [], tracked?: Set<string>): string[] {
|
|
99
133
|
let entries: fs.Dirent[];
|
|
100
134
|
try {
|
|
101
135
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -107,8 +141,12 @@ function walk(dir: string, acc: string[] = []): string[] {
|
|
|
107
141
|
continue;
|
|
108
142
|
}
|
|
109
143
|
const full = path.join(dir, entry.name);
|
|
110
|
-
if (entry.isDirectory()) walk(full, acc);
|
|
111
|
-
else if (/\.(html|md)$/.test(entry.name))
|
|
144
|
+
if (entry.isDirectory()) walk(full, acc, tracked);
|
|
145
|
+
else if (/\.(html|md)$/.test(entry.name)) {
|
|
146
|
+
// Skip anything git does not track, so local and CI score the same set.
|
|
147
|
+
if (tracked && !tracked.has(full)) continue;
|
|
148
|
+
acc.push(full);
|
|
149
|
+
}
|
|
112
150
|
}
|
|
113
151
|
return acc;
|
|
114
152
|
}
|
|
@@ -135,8 +173,9 @@ export function collectShippedSources(
|
|
|
135
173
|
const roots = opts?.roots ?? DEFAULT_ROOTS;
|
|
136
174
|
const out: ShippedSource[] = [];
|
|
137
175
|
|
|
176
|
+
const tracked = trackedFiles(repoRoot);
|
|
138
177
|
for (const root of roots) {
|
|
139
|
-
for (const full of walk(path.join(repoRoot, root))) {
|
|
178
|
+
for (const full of walk(path.join(repoRoot, root), [], tracked)) {
|
|
140
179
|
const rel = path.relative(repoRoot, full);
|
|
141
180
|
if (EXCLUDED.some(e => e.match(rel))) continue;
|
|
142
181
|
const text = fs.readFileSync(full, 'utf8');
|
|
@@ -22,8 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { describe, expect, it } from 'vitest';
|
|
25
|
-
import { parseSemantic } from '@lokascript/semantic';
|
|
26
|
-
import { GrammarTransformer } from '@lokascript/i18n';
|
|
25
|
+
import { parseSemantic, translate } from '@lokascript/semantic';
|
|
27
26
|
|
|
28
27
|
/** Verbatim (action, role, value) triples from a parse tree. */
|
|
29
28
|
function triples(node: unknown, acc: string[] = [], depth = 0): string[] {
|
|
@@ -51,12 +50,40 @@ function triples(node: unknown, acc: string[] = [], depth = 0): string[] {
|
|
|
51
50
|
return acc;
|
|
52
51
|
}
|
|
53
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Renders with @lokascript/semantic, not @lokascript/i18n's `GrammarTransformer`
|
|
55
|
+
* (retired 2026-08-28). The assertions are unchanged and still pass: what they
|
|
56
|
+
* pin is the VOCABULARY, and `lexicon-parity.test.ts` gates semantic's lexicons
|
|
57
|
+
* against i18n's dictionaries, so the two renderers agree on exactly these words
|
|
58
|
+
* — verified on this file's cases before the swap (de `markieren`, ar `ظلل`,
|
|
59
|
+
* bn `#row কে ক্লোন`, id `tutupkan #modal`, vi `thêm vào đầu "x" vào #list`:
|
|
60
|
+
* byte-identical from both).
|
|
61
|
+
*/
|
|
54
62
|
function renderAndParse(en: string, lang: string): { render: string; triples: string[] } {
|
|
55
|
-
const render =
|
|
63
|
+
const render = translate(en, 'en', lang);
|
|
56
64
|
const result = parseSemantic(render, lang);
|
|
57
65
|
return { render, triples: result.node ? triples(result.node) : [] };
|
|
58
66
|
}
|
|
59
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The three EVENT-NAME classes that used to live here — reset, submit and
|
|
70
|
+
* qu change — are gone with the renderer that made them observable.
|
|
71
|
+
*
|
|
72
|
+
* They asserted that i18n's DICTIONARY event word appears in the rendered
|
|
73
|
+
* surface (it `reimpostare`, ko `재설정`, pl `zresetuj`, ru `сбросить`,
|
|
74
|
+
* qu `musuqchay`, …). @lokascript/semantic deliberately does NOT localize an
|
|
75
|
+
* event name: `localizeEventName` keeps a curated denylist of events that must
|
|
76
|
+
* stay English to round-trip, so it renders `on reset`, `on submit`, `on change`
|
|
77
|
+
* verbatim. Migrated as-is, those tests would have asserted only that an English
|
|
78
|
+
* event name comes back as itself — true, and about nothing.
|
|
79
|
+
*
|
|
80
|
+
* The dictionary words themselves still ship (they feed the keyword providers on
|
|
81
|
+
* the PARSE side) and are still gated, by the V1-V4 vocab consistency check
|
|
82
|
+
* (`testing-framework/src/vocab/cli.ts validate`) and `lexicon-parity.test.ts`.
|
|
83
|
+
* What is no longer covered is their appearance in a RENDER, because nothing
|
|
84
|
+
* renders from them any more.
|
|
85
|
+
*/
|
|
86
|
+
|
|
60
87
|
describe('Batch 3 — select class (dict word was the pick keyword)', () => {
|
|
61
88
|
it('de: `select #note` renders markieren and parses back as select', () => {
|
|
62
89
|
const { render, triples: t } = renderAndParse('select #note', 'de');
|
|
@@ -97,51 +124,6 @@ describe('Batch 3 — wrong-verb class (dict word was another command)', () => {
|
|
|
97
124
|
});
|
|
98
125
|
});
|
|
99
126
|
|
|
100
|
-
describe('Batch 3 — reset class (broken/wrong-event listener)', () => {
|
|
101
|
-
it.each([
|
|
102
|
-
['it', 'reimpostare'],
|
|
103
|
-
['ko', '재설정'],
|
|
104
|
-
['pl', 'zresetuj'],
|
|
105
|
-
['pt', 'redefinir'],
|
|
106
|
-
['ru', 'сбросить'],
|
|
107
|
-
['uk', 'скинути'],
|
|
108
|
-
['qu', 'musuqchay'],
|
|
109
|
-
])('%s: on-reset render captures on.event="reset" (canonical)', (langCode, word) => {
|
|
110
|
-
const { render, triples: t } = renderAndParse('on reset log "done"', langCode);
|
|
111
|
-
expect(render).toContain(word);
|
|
112
|
-
expect(t).toContain('on.event=reset');
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe('Batch 3 — submit class (dict word was the send verb)', () => {
|
|
117
|
-
it.each([
|
|
118
|
-
['es', 'envío'],
|
|
119
|
-
['pl', 'wysłaniu'],
|
|
120
|
-
['tr', 'gönderme'],
|
|
121
|
-
['vi', 'nộp'],
|
|
122
|
-
['qu', 'apaykachay'],
|
|
123
|
-
])(
|
|
124
|
-
'%s: corpus-shaped on-submit render captures on.event="submit", not "send"',
|
|
125
|
-
(langCode, word) => {
|
|
126
|
-
const { render, triples: t } = renderAndParse(
|
|
127
|
-
'on submit add @disabled to <button/> in me put "Submitting..." into <button/> in me',
|
|
128
|
-
langCode
|
|
129
|
-
);
|
|
130
|
-
expect(render).toContain(word);
|
|
131
|
-
expect(t).toContain('on.event=submit');
|
|
132
|
-
expect(t).not.toContain('on.event=send');
|
|
133
|
-
}
|
|
134
|
-
);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
describe('Batch 3 — qu change (dict word was the toggle verb)', () => {
|
|
138
|
-
it('qu: on-change render captures on.event="change", not "toggle"', () => {
|
|
139
|
-
const { render, triples: t } = renderAndParse('on change log "x"', 'qu');
|
|
140
|
-
expect(render).toContain('kambiay');
|
|
141
|
-
expect(t).toContain('on.event=change');
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
127
|
describe('Batch 3 — it blur (noun form dropped the command patient)', () => {
|
|
146
128
|
it('it: blur command render captures blur.patient', () => {
|
|
147
129
|
const { render, triples: t } = renderAndParse('on keydown[key=="Escape"] blur me', 'it');
|