@hyperfixi/patterns-reference 2.5.1 → 2.7.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.
@@ -1,3 +1,70 @@
1
+ import { createHash } from 'crypto';
2
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
3
+ import { relative, sep, resolve, dirname, join } from 'path';
4
+
5
+ // src/sync/db-stamp.ts
6
+ function repoRootFromDbPath(dbPath) {
7
+ return resolve(dirname(dbPath), "..", "..", "..");
8
+ }
9
+ function walkTsFiles(dir, acc) {
10
+ if (!existsSync(dir)) return;
11
+ for (const name of readdirSync(dir).sort()) {
12
+ if (name === "node_modules" || name === "dist" || name === "__tests__") continue;
13
+ const full = join(dir, name);
14
+ const st = statSync(full);
15
+ if (st.isDirectory()) {
16
+ walkTsFiles(full, acc);
17
+ } else if (name.endsWith(".ts") && !name.endsWith(".test.ts") && !name.endsWith(".d.ts")) {
18
+ acc.push(full);
19
+ }
20
+ }
21
+ }
22
+ function dbInputFiles(dbPath) {
23
+ const root = repoRootFromDbPath(dbPath);
24
+ const files = [];
25
+ walkTsFiles(join(root, "packages", "i18n", "src"), files);
26
+ walkTsFiles(join(root, "packages", "semantic", "src"), files);
27
+ const pr = join(root, "packages", "patterns-reference");
28
+ for (const f of [
29
+ join(pr, "scripts", "init-db.ts"),
30
+ join(pr, "scripts", "sync-translations.ts"),
31
+ join(pr, "src", "sync", "span-mask.ts"),
32
+ // Committed engine-verification results are seeded into the engine
33
+ // column by init-db.ts, so they are DB input too.
34
+ join(pr, "data", "engine-verification.json")
35
+ ]) {
36
+ if (existsSync(f)) files.push(f);
37
+ }
38
+ return files.sort();
39
+ }
40
+ function computeDbInputHash(dbPath) {
41
+ const root = repoRootFromDbPath(dbPath);
42
+ const files = dbInputFiles(dbPath);
43
+ const h = createHash("sha256");
44
+ h.update(`files:${files.length}\0`);
45
+ for (const f of files) {
46
+ h.update(relative(root, f).split(sep).join("/"));
47
+ h.update("\0");
48
+ h.update(readFileSync(f));
49
+ h.update("\0");
50
+ }
51
+ return h.digest("hex");
52
+ }
53
+ function dbStampPath(dbPath) {
54
+ return `${dbPath}.stamp`;
55
+ }
56
+ function writeDbStamp(dbPath) {
57
+ writeFileSync(dbStampPath(dbPath), `${computeDbInputHash(dbPath)}
58
+ `, "utf8");
59
+ }
60
+ function checkDbStamp(dbPath) {
61
+ const sp = dbStampPath(dbPath);
62
+ if (!existsSync(sp)) return { status: "unstamped" };
63
+ const expected = readFileSync(sp, "utf8").trim();
64
+ const actual = computeDbInputHash(dbPath);
65
+ return actual === expected ? { status: "ok" } : { status: "stale", expected, actual };
66
+ }
67
+
1
68
  // src/sync/index.ts
2
69
  async function syncTranslations(_options = {}) {
3
70
  throw new Error(
@@ -20,4 +87,4 @@ async function seedLLMExamples(_dryRun = false) {
20
87
  );
21
88
  }
22
89
 
23
- export { discoverPatterns, seedLLMExamples, syncTranslations, validateAllTranslations };
90
+ export { checkDbStamp, computeDbInputHash, dbStampPath, discoverPatterns, seedLLMExamples, syncTranslations, validateAllTranslations, writeDbStamp };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperfixi/patterns-reference",
3
- "version": "2.5.1",
3
+ "version": "2.7.0",
4
4
  "description": "Queryable patterns database for hyperscript with multilingual translations and LLM support",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -39,10 +39,8 @@
39
39
  "validate:fix": "tsx scripts/validate-all.ts --fix",
40
40
  "verify": "tsx scripts/verify-translations.ts",
41
41
  "verify:verbose": "tsx scripts/verify-translations.ts --verbose",
42
- "verify:engine": "tsx scripts/verify-engine-compat.ts",
43
- "verify:engine:verbose": "tsx scripts/verify-engine-compat.ts --verbose",
44
- "verify:engine:dry-run": "tsx scripts/verify-engine-compat.ts --dry-run --verbose",
45
- "test:check": "vitest run --reporter=dot 2>&1 | tail -5"
42
+ "test:check": "VITEST_QUIET=1 bash ../../scripts/vitest-run.sh --reporter=dot",
43
+ "verify:engines": "tsx scripts/verify-engines.ts --update-db"
46
44
  },
47
45
  "keywords": [
48
46
  "hyperscript",
@@ -56,7 +54,7 @@
56
54
  "author": "LokaScript Contributors",
57
55
  "license": "MIT",
58
56
  "engines": {
59
- "node": ">=18.0.0"
57
+ "node": ">=24"
60
58
  },
61
59
  "repository": {
62
60
  "type": "git",
@@ -68,12 +66,17 @@
68
66
  },
69
67
  "homepage": "https://github.com/codetalcott/hyperfixi/tree/main/packages/patterns-reference#readme",
70
68
  "dependencies": {
71
- "@lokascript/semantic": "^2.5.1",
69
+ "@lokascript/semantic": "^2.7.0",
72
70
  "better-sqlite3": "^12.6.2"
73
71
  },
74
72
  "devDependencies": {
73
+ "@hyperfixi/core": "^2.7.0",
74
+ "@hyperfixi/reactivity": "^2.7.0",
75
+ "@hyperfixi/realtime": "^2.7.0",
75
76
  "@types/better-sqlite3": "^7.6.0",
76
77
  "@types/node": "^20.0.0",
78
+ "hyperscript.org": "0.9.93",
79
+ "jsdom": "^26.0.0",
77
80
  "tsup": "^8.0.0",
78
81
  "tsx": "^4.0.0",
79
82
  "typescript": "^5.0.0",
@@ -225,12 +225,14 @@ export async function getPatternStats(connOptions?: ConnectionOptions): Promise<
225
225
  /**
226
226
  * Get behavior definition patterns.
227
227
  *
228
- * Returns patterns with feature='behavior' — these are installable
229
- * behavior definitions (behavior Name(params) ... end) that can be
230
- * compiled and registered with a LokaScript runtime.
228
+ * Returns installable behavior definitions (behavior Name(params) ... end)
229
+ * that can be compiled and registered with a LokaScript runtime. The
230
+ * 'behavior' category also holds usage examples (`install Draggable`,
231
+ * `tell #modal …`), so filter to actual definitions by source shape.
231
232
  */
232
233
  export async function getBehaviorPatterns(options?: ConnectionOptions): Promise<Pattern[]> {
233
- return getPatternsByCategory('behavior', options);
234
+ const patterns = await getPatternsByCategory('behavior', options);
235
+ return patterns.filter(p => /^\s*behavior\s/.test(p.rawCode));
234
236
  }
235
237
 
236
238
  // =============================================================================
@@ -24,6 +24,15 @@ const DEFAULT_DB_PATH =
24
24
  let dbInstance: InstanceType<typeof Database> | null = null;
25
25
  let currentDbPath: string | null = null;
26
26
 
27
+ /**
28
+ * The resolved default DB path (honouring `LSP_DB_PATH` / `HYPERSCRIPT_LSP_DB`).
29
+ * Exposed so callers (e.g. the multilingual gate's freshness check) can target the
30
+ * exact DB that `getDatabase()` opens.
31
+ */
32
+ export function getDefaultDbPath(): string {
33
+ return DEFAULT_DB_PATH;
34
+ }
35
+
27
36
  /**
28
37
  * Get database connection (lazy singleton).
29
38
  */
package/src/index.ts CHANGED
@@ -83,6 +83,7 @@ export {
83
83
  resetConnection,
84
84
  isConnected,
85
85
  getCurrentDbPath,
86
+ getDefaultDbPath,
86
87
  } from './database/connection';
87
88
 
88
89
  // =============================================================================
@@ -166,6 +167,11 @@ export {
166
167
  validateAllTranslations,
167
168
  discoverPatterns,
168
169
  seedLLMExamples,
170
+ computeDbInputHash,
171
+ dbStampPath,
172
+ writeDbStamp,
173
+ checkDbStamp,
174
+ type DbStampStatus,
169
175
  } from './sync';
170
176
 
171
177
  // =============================================================================
@@ -0,0 +1,111 @@
1
+ /**
2
+ * DB provenance stamp — guards against running the multilingual gate against a
3
+ * `patterns.db` that was generated from *different* source than is currently checked
4
+ * out (the cross-branch "phantom regression" footgun).
5
+ *
6
+ * The DB's content (transformer translation text + stored parser confidence /
7
+ * verified_parses) is determined by the i18n + semantic **source**, plus the seed and
8
+ * sync scripts. We hash that source (not the gitignored, build-non-deterministic
9
+ * `dist/`) so the stamp catches a branch / source change regardless of whether `dist`
10
+ * happened to be rebuilt. `sync-translations` writes the stamp next to the DB; the
11
+ * gate verifies it before trusting a comparison against the committed baseline.
12
+ *
13
+ * The stamp file (`patterns.db.stamp`) is a **local** artifact (gitignored) — it
14
+ * records "what source produced *my* local DB", which is exactly what the gate needs
15
+ * to confirm the DB is fresh for the current working tree.
16
+ */
17
+
18
+ import { createHash } from 'node:crypto';
19
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
20
+ import { dirname, join, relative, resolve, sep } from 'node:path';
21
+
22
+ /**
23
+ * Resolve the monorepo root from a `patterns.db` path. Standard layout:
24
+ * `<root>/packages/patterns-reference/data/patterns.db` → up 3 from `data/`.
25
+ */
26
+ function repoRootFromDbPath(dbPath: string): string {
27
+ return resolve(dirname(dbPath), '..', '..', '..');
28
+ }
29
+
30
+ /** Recursively collect non-test `.ts` source files under `dir`. */
31
+ function walkTsFiles(dir: string, acc: string[]): void {
32
+ if (!existsSync(dir)) return;
33
+ for (const name of readdirSync(dir).sort()) {
34
+ if (name === 'node_modules' || name === 'dist' || name === '__tests__') continue;
35
+ const full = join(dir, name);
36
+ const st = statSync(full);
37
+ if (st.isDirectory()) {
38
+ walkTsFiles(full, acc);
39
+ } else if (name.endsWith('.ts') && !name.endsWith('.test.ts') && !name.endsWith('.d.ts')) {
40
+ acc.push(full);
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * The source files whose content determines `patterns.db`: the i18n transformer +
47
+ * dictionaries + grammar profiles (translation text), the semantic parser + profiles
48
+ * (stored confidence + the language set), and the seed / sync scripts.
49
+ */
50
+ function dbInputFiles(dbPath: string): string[] {
51
+ const root = repoRootFromDbPath(dbPath);
52
+ const files: string[] = [];
53
+ walkTsFiles(join(root, 'packages', 'i18n', 'src'), files);
54
+ walkTsFiles(join(root, 'packages', 'semantic', 'src'), files);
55
+ const pr = join(root, 'packages', 'patterns-reference');
56
+ for (const f of [
57
+ join(pr, 'scripts', 'init-db.ts'),
58
+ join(pr, 'scripts', 'sync-translations.ts'),
59
+ join(pr, 'src', 'sync', 'span-mask.ts'),
60
+ // Committed engine-verification results are seeded into the engine
61
+ // column by init-db.ts, so they are DB input too.
62
+ join(pr, 'data', 'engine-verification.json'),
63
+ ]) {
64
+ if (existsSync(f)) files.push(f);
65
+ }
66
+ return files.sort();
67
+ }
68
+
69
+ /**
70
+ * Compute the provenance hash of the DB's source inputs. Stable across machines (it
71
+ * hashes committed source, keyed by repo-relative path).
72
+ */
73
+ export function computeDbInputHash(dbPath: string): string {
74
+ const root = repoRootFromDbPath(dbPath);
75
+ const files = dbInputFiles(dbPath);
76
+ const h = createHash('sha256');
77
+ h.update(`files:${files.length}\0`);
78
+ for (const f of files) {
79
+ h.update(relative(root, f).split(sep).join('/'));
80
+ h.update('\0');
81
+ h.update(readFileSync(f));
82
+ h.update('\0');
83
+ }
84
+ return h.digest('hex');
85
+ }
86
+
87
+ export function dbStampPath(dbPath: string): string {
88
+ return `${dbPath}.stamp`;
89
+ }
90
+
91
+ /** Write the provenance stamp next to the DB (called at the end of sync). */
92
+ export function writeDbStamp(dbPath: string): void {
93
+ writeFileSync(dbStampPath(dbPath), `${computeDbInputHash(dbPath)}\n`, 'utf8');
94
+ }
95
+
96
+ export type DbStampStatus =
97
+ | { status: 'ok' }
98
+ | { status: 'unstamped' }
99
+ | { status: 'stale'; expected: string; actual: string };
100
+
101
+ /**
102
+ * Verify the DB's stamp against the current source. `unstamped` (no sidecar) is
103
+ * returned when the DB predates this guard — callers should warn, not hard-fail.
104
+ */
105
+ export function checkDbStamp(dbPath: string): DbStampStatus {
106
+ const sp = dbStampPath(dbPath);
107
+ if (!existsSync(sp)) return { status: 'unstamped' };
108
+ const expected = readFileSync(sp, 'utf8').trim();
109
+ const actual = computeDbInputHash(dbPath);
110
+ return actual === expected ? { status: 'ok' } : { status: 'stale', expected, actual };
111
+ }
package/src/sync/index.ts CHANGED
@@ -68,3 +68,12 @@ export async function seedLLMExamples(_dryRun: boolean = false): Promise<{ count
68
68
  'seedLLMExamples should be run via: bun run packages/semantic/scripts/seed-llm-examples.ts'
69
69
  );
70
70
  }
71
+
72
+ // DB provenance stamp — freshness guard for the multilingual gate.
73
+ export {
74
+ computeDbInputHash,
75
+ dbStampPath,
76
+ writeDbStamp,
77
+ checkDbStamp,
78
+ type DbStampStatus,
79
+ } from './db-stamp';
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Span-aware masking for hyperscript code prior to translation.
3
+ *
4
+ * The grammar transformer and keyword-substitute fallback both treat hyperscript
5
+ * source as a flat token stream. They will happily reorder text inside HTML
6
+ * elements, translate words inside string literals, and shred bracket
7
+ * expressions like `[key is 'Escape']`. To avoid that, we replace
8
+ * non-translatable spans with opaque placeholder tokens before translation,
9
+ * then restore them after.
10
+ *
11
+ * Placeholders use the form `__HFXMSK_<idx>_<KIND>__`. Underscores on both
12
+ * sides make the placeholder a single "word" to JS regex `\b` boundaries —
13
+ * keyword substitution `\bword\b` cannot match a substring inside the
14
+ * placeholder, regardless of case-insensitivity flags.
15
+ */
16
+
17
+ export type SpanKind =
18
+ | 'string-single'
19
+ | 'string-double'
20
+ | 'string-template'
21
+ | 'url'
22
+ | 'html-text'
23
+ | 'directive'
24
+ | 'bracket-expr'
25
+ | 'js-block';
26
+
27
+ export interface MaskedSpan {
28
+ kind: SpanKind;
29
+ original: string;
30
+ placeholder: string;
31
+ }
32
+
33
+ export interface MaskResult {
34
+ masked: string;
35
+ spans: MaskedSpan[];
36
+ }
37
+
38
+ const KIND_TAG: Record<SpanKind, string> = {
39
+ 'string-single': 'STR1',
40
+ 'string-double': 'STR2',
41
+ 'string-template': 'STRT',
42
+ url: 'URL',
43
+ 'html-text': 'TEXT',
44
+ directive: 'DIR',
45
+ 'bracket-expr': 'BRK',
46
+ 'js-block': 'JS',
47
+ };
48
+
49
+ function makePlaceholder(kind: SpanKind, idx: number): string {
50
+ return `__HFXMSK_${idx}_${KIND_TAG[kind]}__`;
51
+ }
52
+
53
+ const PLACEHOLDER_RE = /__HFXMSK_(\d+)_(?:STR1|STR2|STRT|URL|TEXT|DIR|BRK|JS)__/g;
54
+
55
+ /**
56
+ * Detect non-translatable spans in hyperscript code and replace them with
57
+ * opaque placeholders. The detection order is significant: earlier rules
58
+ * consume characters that later rules would otherwise misinterpret.
59
+ *
60
+ * 1. Template literals (consume `${...}` interpolation contents)
61
+ * 2. Double-quoted strings (consume HTML attribute values)
62
+ * 3. Single-quoted strings (escaping possessive `'s`)
63
+ * 4. HTML inner text (between `>` and `<` with non-whitespace)
64
+ * 5. Bracket expressions (event filters like `[key is 'Escape']`)
65
+ * 6. URL-like tokens (paths and protocol://host)
66
+ * 7. Component template directives (`#if`, `#end`, `#for`, ...)
67
+ */
68
+ export function maskSpans(input: string): MaskResult {
69
+ let working = input;
70
+ const spans: MaskedSpan[] = [];
71
+
72
+ const record = (kind: SpanKind, original: string): string => {
73
+ const placeholder = makePlaceholder(kind, spans.length);
74
+ spans.push({ kind, original, placeholder });
75
+ return placeholder;
76
+ };
77
+
78
+ // 0. Embedded JS blocks: `js(args) ... end` opens a raw-JavaScript escape
79
+ // hatch inside hyperscript. Its body must be passed through verbatim;
80
+ // the grammar transformer would otherwise tokenize JavaScript as if
81
+ // it were hyperscript and silently drop unrecognized tokens. Mask this
82
+ // first because the body can contain any other span kind.
83
+ working = working.replace(/\bjs\([^)]*\)[\s\S]*?\bend\b/g, m => record('js-block', m));
84
+
85
+ // 1. Template literals — match outermost backtick body with escape support.
86
+ // The body may contain `${...}` interpolations; treat the whole literal
87
+ // as one opaque span. Inner code stays English but is structurally safe.
88
+ working = working.replace(/`(?:\\.|[^`\\])*`/g, m => record('string-template', m));
89
+
90
+ // 2. Double-quoted strings. This also captures HTML attribute values so
91
+ // `component="my-layout"` cannot have `my` translated.
92
+ working = working.replace(/"(?:\\.|[^"\\])*"/g, m => record('string-double', m));
93
+
94
+ // 3. Single-quoted strings, but NOT possessive `'s` (preceded by a word char).
95
+ // The lookbehind ensures `me's value 'foo'` matches only `'foo'`.
96
+ working = working.replace(/(?<!\w)'(?:\\.|[^'\\])*'/g, m => record('string-single', m));
97
+
98
+ // 4. HTML inner text content. Match a non-whitespace text run between
99
+ // a `>` and the next `<`, on the SAME line. Newline confinement is
100
+ // critical: without it the regex would span multiple element boundaries
101
+ // and consume nested tags as if they were text. The middle character
102
+ // class `[^<\n\s]` ensures at least one printable non-`<` char exists
103
+ // so pure indentation between elements is not masked.
104
+ // The `(?<!\/)` lookbehind excludes a `>` that closes a SELF-CLOSING
105
+ // selector literal (`<button/>`): hyperscript between two selector
106
+ // literals (`… last <button/> in .modal focus first <button/> …`) is
107
+ // real code, not element inner text — masking it hid the whole segment
108
+ // from the transformer, which then emitted it untranslated (the
109
+ // focus-trap `focus first` leak).
110
+ working = working.replace(
111
+ /(?<!\/)>([^<\n]*[^<\n\s][^<\n]*)</g,
112
+ (_m, text) => `>${record('html-text', text)}<`
113
+ );
114
+
115
+ // 5. Bracket expressions. Mask the entire `[...]` payload as one unit so
116
+ // SOV/VSO reordering cannot move tokens out of the brackets. Non-nested
117
+ // is fine for the current corpus; bracket-inside-bracket isn't used.
118
+ working = working.replace(/\[[^\]]*\]/g, m => record('bracket-expr', m));
119
+
120
+ // 6. URL-like tokens. Two forms:
121
+ // - protocol://host (http, https, ws, wss, file)
122
+ // - /path (whitespace-bounded, not preceded by a word char to avoid
123
+ // matching arithmetic like `1/2`)
124
+ // The path form matches a bare `/` too (precache root) when followed
125
+ // by non-word boundary. Path chars: word, dash, dot, slash, glob, query.
126
+ working = working.replace(/(?:https?|wss?|file):\/\/[^\s,)]+|(?<![\w<])\/[\w\-./*?=&]*/g, m =>
127
+ record('url', m)
128
+ );
129
+
130
+ // 7. Component template directives. Mask the `#keyword` token only — the
131
+ // expression after it (e.g. `^user.admin`) is left translatable, since
132
+ // those are typically variable references that pass through unchanged.
133
+ working = working.replace(/#(?:if|else|elif|end|for|each)\b/g, m => record('directive', m));
134
+
135
+ return { masked: working, spans };
136
+ }
137
+
138
+ /**
139
+ * Restore placeholders to their original spans. Iterates to a fixed point so
140
+ * that an `original` field containing another placeholder (which shouldn't
141
+ * happen with the current detection rules, but is cheap insurance) is fully
142
+ * resolved.
143
+ */
144
+ export function unmaskSpans(translated: string, spans: MaskedSpan[]): string {
145
+ let result = translated;
146
+ let prev: string;
147
+ do {
148
+ prev = result;
149
+ result = result.replace(PLACEHOLDER_RE, (m, idx) => {
150
+ const span = spans[Number(idx)];
151
+ return span ? span.original : m;
152
+ });
153
+ } while (result !== prev);
154
+ return result;
155
+ }
156
+
157
+ /**
158
+ * Convenience wrapper: mask, run a transform on the masked surface, then
159
+ * unmask. The transform receives only the masked string and must preserve
160
+ * placeholders (which it will, since they look like ordinary identifiers).
161
+ */
162
+ export function withMaskedSpans(input: string, transform: (masked: string) => string): string {
163
+ const { masked, spans } = maskSpans(input);
164
+ const transformed = transform(masked);
165
+ return unmaskSpans(transformed, spans);
166
+ }