@lokascript/framework 4.6.0 → 4.8.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 +121 -1
- package/dist/api/create-dsl.d.ts +18 -2
- package/dist/api/create-dsl.d.ts.map +1 -1
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +1799 -713
- package/dist/api/index.js.map +1 -1
- package/dist/api/translation-loss.d.ts +84 -0
- package/dist/api/translation-loss.d.ts.map +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1057 -684
- package/dist/core/index.js.map +1 -1
- package/dist/core/matching/confidence-model.d.ts +54 -0
- package/dist/core/matching/confidence-model.d.ts.map +1 -0
- package/dist/core/matching/index.d.ts +12 -0
- package/dist/core/matching/index.d.ts.map +1 -0
- package/dist/core/matching/match-skeleton.d.ts +114 -0
- package/dist/core/matching/match-skeleton.d.ts.map +1 -0
- package/dist/core/matching/stock-confidence.d.ts +15 -0
- package/dist/core/matching/stock-confidence.d.ts.map +1 -0
- package/dist/core/matching/stock-reader.d.ts +63 -0
- package/dist/core/matching/stock-reader.d.ts.map +1 -0
- package/dist/core/matching/stock-reading.d.ts +44 -0
- package/dist/core/matching/stock-reading.d.ts.map +1 -0
- package/dist/core/matching/value-reader.d.ts +62 -0
- package/dist/core/matching/value-reader.d.ts.map +1 -0
- package/dist/core/pattern-matching/index.js +561 -660
- package/dist/core/pattern-matching/index.js.map +1 -1
- package/dist/core/pattern-matching/pattern-matcher.d.ts +0 -117
- package/dist/core/pattern-matching/pattern-matcher.d.ts.map +1 -1
- package/dist/core/types.d.ts +53 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/generation/index.d.ts +2 -0
- package/dist/generation/index.d.ts.map +1 -1
- package/dist/generation/index.js +593 -1
- package/dist/generation/index.js.map +1 -1
- package/dist/generation/pattern-render.d.ts +72 -0
- package/dist/generation/pattern-render.d.ts.map +1 -0
- package/dist/generation/schema-patterns.d.ts +152 -0
- package/dist/generation/schema-patterns.d.ts.map +1 -0
- package/dist/index.cjs +2016 -904
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1998 -903
- package/dist/index.js.map +1 -1
- package/dist/ir/index.js.map +1 -1
- package/dist/parsing/index.js +561 -660
- package/dist/parsing/index.js.map +1 -1
- package/dist/testing/gate/index.d.ts +8 -0
- package/dist/testing/gate/index.d.ts.map +1 -0
- package/dist/testing/gate/index.js +664 -0
- package/dist/testing/gate/index.js.map +1 -0
- package/dist/testing/gate/lint/format.d.ts +7 -0
- package/dist/testing/gate/lint/format.d.ts.map +1 -0
- package/dist/testing/gate/lint/index.d.ts +30 -0
- package/dist/testing/gate/lint/index.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/extractor-coverage.d.ts +18 -0
- package/dist/testing/gate/lint/rules/extractor-coverage.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/index.d.ts +14 -0
- package/dist/testing/gate/lint/rules/index.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/keyword-classification.d.ts +19 -0
- package/dist/testing/gate/lint/rules/keyword-classification.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/keyword-coverage.d.ts +13 -0
- package/dist/testing/gate/lint/rules/keyword-coverage.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/marker-tokenization.d.ts +20 -0
- package/dist/testing/gate/lint/rules/marker-tokenization.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/position-ordering.d.ts +25 -0
- package/dist/testing/gate/lint/rules/position-ordering.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/renderer-coherence.d.ts +24 -0
- package/dist/testing/gate/lint/rules/renderer-coherence.d.ts.map +1 -0
- package/dist/testing/gate/lint/rules/schema-rules.d.ts +10 -0
- package/dist/testing/gate/lint/rules/schema-rules.d.ts.map +1 -0
- package/dist/testing/gate/lint/types.d.ts +74 -0
- package/dist/testing/gate/lint/types.d.ts.map +1 -0
- package/dist/testing/gate/round-trip.d.ts +184 -0
- package/dist/testing/gate/round-trip.d.ts.map +1 -0
- package/package.json +6 -2
- package/src/__test__/fixtures/sql-dsl.ts +8 -2
- package/src/api/create-dsl-engine.test.ts +150 -0
- package/src/api/create-dsl.test.ts +7 -0
- package/src/api/create-dsl.ts +173 -22
- package/src/api/index.ts +1 -0
- package/src/api/translation-loss.test.ts +89 -0
- package/src/api/translation-loss.ts +128 -0
- package/src/core/index.ts +1 -0
- package/src/core/matching/confidence-model.ts +58 -0
- package/src/core/matching/index.ts +12 -0
- package/src/core/matching/match-skeleton.test.ts +179 -0
- package/src/core/matching/match-skeleton.ts +621 -0
- package/src/core/matching/stock-confidence.ts +238 -0
- package/src/core/matching/stock-reader.test.ts +265 -0
- package/src/core/matching/stock-reader.ts +205 -0
- package/src/core/matching/stock-reading.ts +681 -0
- package/src/core/matching/value-reader.ts +85 -0
- package/src/core/pattern-matching/pattern-matcher.ts +11 -876
- package/src/core/types.ts +53 -0
- package/src/generation/index.ts +2 -0
- package/src/generation/pattern-render.ts +378 -0
- package/src/generation/schema-patterns.test.ts +139 -0
- package/src/generation/schema-patterns.ts +974 -0
- package/src/testing/gate/index.ts +8 -0
- package/src/testing/gate/lint/format.ts +23 -0
- package/src/testing/gate/lint/index.ts +73 -0
- package/src/testing/gate/lint/lint.test.ts +131 -0
- package/src/testing/gate/lint/rules/extractor-coverage.ts +63 -0
- package/src/testing/gate/lint/rules/index.ts +37 -0
- package/src/testing/gate/lint/rules/keyword-classification.ts +52 -0
- package/src/testing/gate/lint/rules/keyword-coverage.ts +38 -0
- package/src/testing/gate/lint/rules/marker-tokenization.ts +129 -0
- package/src/testing/gate/lint/rules/position-ordering.ts +87 -0
- package/src/testing/gate/lint/rules/renderer-coherence.ts +100 -0
- package/src/testing/gate/lint/rules/schema-rules.ts +35 -0
- package/src/testing/gate/lint/types.ts +83 -0
- package/src/testing/gate/round-trip.test.ts +208 -0
- package/src/testing/gate/round-trip.ts +503 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @lokascript/framework/testing/gate — the round-trip gate kit and the grammar linter
|
|
3
|
+
* (domains rethink Phase 4.6). No test runner needed: a grammar's gate test or regen
|
|
4
|
+
* tool calls `runRoundTrip` and `compare`, its lint test `lintDomain`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export * from './round-trip';
|
|
8
|
+
export * from './lint';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pretty-print lint results for test output.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { LintFinding, LintResult, Severity } from './types';
|
|
6
|
+
|
|
7
|
+
const SEVERITY_GLYPH: Record<Severity, string> = {
|
|
8
|
+
error: '✗',
|
|
9
|
+
warning: '⚠',
|
|
10
|
+
note: 'ℹ',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function formatFinding(finding: LintFinding): string {
|
|
14
|
+
const glyph = SEVERITY_GLYPH[finding.severity];
|
|
15
|
+
return ` ${glyph} [${finding.rule}] ${finding.message}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatResult(result: LintResult): string {
|
|
19
|
+
const header = `domain-toolkit: lint(${result.domain}) — ${result.errorCount} error(s), ${result.warningCount} warning(s)`;
|
|
20
|
+
if (result.findings.length === 0) return `${header}\n (clean)`;
|
|
21
|
+
const body = result.findings.map(formatFinding).join('\n');
|
|
22
|
+
return `${header}\n${body}`;
|
|
23
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The domain linter (domains rethink Phase 4.6): moved from lokascript-domains'
|
|
3
|
+
* `@lokascript/domain-toolkit`. Checks schema structure and cross-file
|
|
4
|
+
* invariants (profile ↔ tokenizer ↔ schema ↔ renderer agreement); a grammar
|
|
5
|
+
* runs it from one lint test.
|
|
6
|
+
*
|
|
7
|
+
* Implemented rules:
|
|
8
|
+
* - R1/R2 (schema-structure): delegates to the validator the input
|
|
9
|
+
* brings (`validateSchema`: the domains pass @lokascript/semantic's).
|
|
10
|
+
* - R5 (keyword-classification): profile keywords/markers classify as
|
|
11
|
+
* 'keyword' in the language's tokenizer.
|
|
12
|
+
* - R6 (keyword-coverage): every schema command has profile keywords for
|
|
13
|
+
* all declared languages.
|
|
14
|
+
* - R7 (marker-tokenization): every marker/keyword tokenizes as a single
|
|
15
|
+
* token.
|
|
16
|
+
* - R8 (extractor-coverage): Latin-script tokenizers handle diacritic
|
|
17
|
+
* identifiers (warning).
|
|
18
|
+
* - R9 (position-ordering): schema role positions are unique per
|
|
19
|
+
* word-order (warning).
|
|
20
|
+
* - R10 (renderer-coherence): renderer table keywords match
|
|
21
|
+
* profile/schema translations (opt-in via `renderer`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { runRules } from './rules';
|
|
25
|
+
import type { DomainLintInput, LintFinding, LintResult, LintWaiver } from './types';
|
|
26
|
+
|
|
27
|
+
export type {
|
|
28
|
+
DomainLintInput,
|
|
29
|
+
LintResult,
|
|
30
|
+
LintFinding,
|
|
31
|
+
LintRule,
|
|
32
|
+
LintWaiver,
|
|
33
|
+
Severity,
|
|
34
|
+
RendererTables,
|
|
35
|
+
SchemaValidationFinding,
|
|
36
|
+
SchemaValidator,
|
|
37
|
+
} from './types';
|
|
38
|
+
export { formatResult, formatFinding } from './format';
|
|
39
|
+
|
|
40
|
+
function matchesWaiver(finding: LintFinding, waiver: LintWaiver): boolean {
|
|
41
|
+
if (finding.rule !== waiver.rule) return false;
|
|
42
|
+
if (!waiver.matches) return true;
|
|
43
|
+
const ctx = finding.context ?? {};
|
|
44
|
+
for (const [key, expected] of Object.entries(waiver.matches)) {
|
|
45
|
+
if (ctx[key] !== expected) return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function applyWaivers(
|
|
51
|
+
findings: LintFinding[],
|
|
52
|
+
waivers: readonly LintWaiver[] | undefined
|
|
53
|
+
): LintFinding[] {
|
|
54
|
+
if (!waivers || waivers.length === 0) return findings;
|
|
55
|
+
return findings.filter(f => !waivers.some(w => matchesWaiver(f, w)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Lint a domain against all enabled rules and return a structured result.
|
|
60
|
+
*/
|
|
61
|
+
export function lintDomain(input: DomainLintInput): LintResult {
|
|
62
|
+
const raw = runRules(input);
|
|
63
|
+
const findings = applyWaivers(raw, input.waivers);
|
|
64
|
+
const errorCount = findings.filter(f => f.severity === 'error').length;
|
|
65
|
+
const warningCount = findings.filter(f => f.severity === 'warning').length;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
domain: input.name,
|
|
69
|
+
findings,
|
|
70
|
+
errorCount,
|
|
71
|
+
warningCount,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The linter's own logic: waiver matching, count derivation, the format helpers,
|
|
3
|
+
* and the schema-structure rule's validator input (moved with the linter from
|
|
4
|
+
* lokascript-domains' domain-toolkit). A grammar's lint test runs the rules on its
|
|
5
|
+
* real tables; these fixtures are minimal on purpose (keyword-coverage needs only
|
|
6
|
+
* `schema.action` + `profile.keywords`), and assertions filter by rule id.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { lintDomain, formatResult, formatFinding } from './index';
|
|
11
|
+
import type { DomainLintInput } from './types';
|
|
12
|
+
|
|
13
|
+
function emptyInput(): DomainLintInput {
|
|
14
|
+
return { name: 'smoke', schemas: [], profiles: [], tokenizers: {} };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** One schema + a profile with no keywords → exactly one R6 error finding. */
|
|
18
|
+
function uncoveredInput(): DomainLintInput {
|
|
19
|
+
return {
|
|
20
|
+
name: 'smoke',
|
|
21
|
+
schemas: [
|
|
22
|
+
{
|
|
23
|
+
action: 'toggle',
|
|
24
|
+
description: 'fixture command',
|
|
25
|
+
roles: [],
|
|
26
|
+
primaryRole: 'patient',
|
|
27
|
+
category: 'test',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
profiles: [{ code: 'xx', wordOrder: 'SVO', keywords: {} }],
|
|
31
|
+
tokenizers: {},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('lintDomain', () => {
|
|
36
|
+
it('returns a clean structured result for an empty domain', () => {
|
|
37
|
+
const result = lintDomain(emptyInput());
|
|
38
|
+
expect(result.domain).toBe('smoke');
|
|
39
|
+
expect(result.findings).toEqual([]);
|
|
40
|
+
expect(result.errorCount).toBe(0);
|
|
41
|
+
expect(result.warningCount).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('reports keyword-coverage when a profile lacks a schema action keyword', () => {
|
|
45
|
+
const result = lintDomain(uncoveredInput());
|
|
46
|
+
const r6 = result.findings.filter(f => f.rule === 'keyword-coverage');
|
|
47
|
+
expect(r6).toHaveLength(1);
|
|
48
|
+
expect(r6[0]).toMatchObject({
|
|
49
|
+
severity: 'error',
|
|
50
|
+
context: { domain: 'smoke', lang: 'xx', action: 'toggle' },
|
|
51
|
+
});
|
|
52
|
+
expect(result.errorCount).toBeGreaterThanOrEqual(1);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('waivers', () => {
|
|
57
|
+
it('drops a finding whose rule and context both match', () => {
|
|
58
|
+
const result = lintDomain({
|
|
59
|
+
...uncoveredInput(),
|
|
60
|
+
waivers: [{ rule: 'keyword-coverage', reason: 'fixture waiver', matches: { lang: 'xx' } }],
|
|
61
|
+
});
|
|
62
|
+
expect(result.findings.filter(f => f.rule === 'keyword-coverage')).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('keeps a finding when the waiver context does not match', () => {
|
|
66
|
+
const result = lintDomain({
|
|
67
|
+
...uncoveredInput(),
|
|
68
|
+
waivers: [{ rule: 'keyword-coverage', reason: 'wrong language', matches: { lang: 'yy' } }],
|
|
69
|
+
});
|
|
70
|
+
expect(result.findings.filter(f => f.rule === 'keyword-coverage')).toHaveLength(1);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('a matches-less waiver drops every finding of its rule', () => {
|
|
74
|
+
const result = lintDomain({
|
|
75
|
+
...uncoveredInput(),
|
|
76
|
+
waivers: [{ rule: 'keyword-coverage', reason: 'blanket' }],
|
|
77
|
+
});
|
|
78
|
+
expect(result.findings.filter(f => f.rule === 'keyword-coverage')).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('format helpers', () => {
|
|
83
|
+
it('formatFinding renders the severity glyph and rule id', () => {
|
|
84
|
+
const line = formatFinding({
|
|
85
|
+
rule: 'keyword-coverage',
|
|
86
|
+
severity: 'error',
|
|
87
|
+
message: 'profile [xx] has no keyword',
|
|
88
|
+
});
|
|
89
|
+
expect(line).toContain('✗');
|
|
90
|
+
expect(line).toContain('[keyword-coverage]');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('formatResult renders "(clean)" for an empty result', () => {
|
|
94
|
+
const text = formatResult(lintDomain(emptyInput()));
|
|
95
|
+
expect(text).toContain('0 error(s), 0 warning(s)');
|
|
96
|
+
expect(text).toContain('(clean)');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('formatResult renders one line per finding otherwise', () => {
|
|
100
|
+
const text = formatResult(lintDomain(uncoveredInput()));
|
|
101
|
+
expect(text).toContain("no keyword for action 'toggle'");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('schema-structure (R1/R2)', () => {
|
|
106
|
+
it("reports the input's validator findings, notes dropped", () => {
|
|
107
|
+
const result = lintDomain({
|
|
108
|
+
...uncoveredInput(),
|
|
109
|
+
validateSchema: schema => ({
|
|
110
|
+
items: [
|
|
111
|
+
{ code: 'NO_REQUIRED_ROLES', severity: 'warning', message: `${schema.action} has none` },
|
|
112
|
+
{ code: 'A_NOTE', severity: 'note', message: 'dropped' },
|
|
113
|
+
],
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
const r1 = result.findings.filter(f => f.rule.startsWith('schema-structure'));
|
|
117
|
+
expect(r1).toEqual([
|
|
118
|
+
{
|
|
119
|
+
rule: 'schema-structure:NO_REQUIRED_ROLES',
|
|
120
|
+
severity: 'warning',
|
|
121
|
+
message: '[toggle] toggle has none',
|
|
122
|
+
context: { domain: 'smoke', action: 'toggle', code: 'NO_REQUIRED_ROLES' },
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('finds nothing without a validator', () => {
|
|
128
|
+
const result = lintDomain(uncoveredInput());
|
|
129
|
+
expect(result.findings.filter(f => f.rule.startsWith('schema-structure'))).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R8: Latin-script tokenizers should extract diacritic identifiers as single tokens.
|
|
3
|
+
*
|
|
4
|
+
* The default `IdentifierExtractor` matches `[a-zA-Z0-9_]*` and stops at any
|
|
5
|
+
* diacritic. Words like `añadir` split into `["a", "ñadir"]` unless the
|
|
6
|
+
* tokenizer also registers `LatinExtendedIdentifierExtractor`. This rule
|
|
7
|
+
* probes the behavior by tokenizing a canonical diacritic-containing string
|
|
8
|
+
* for each language and warning if it doesn't come back as a single token.
|
|
9
|
+
*
|
|
10
|
+
* Warning (not error) because:
|
|
11
|
+
* - English tokenizers legitimately don't need the extended extractor.
|
|
12
|
+
* - A domain might deliberately split compounds for its own reasons.
|
|
13
|
+
* The warning prompts the author to audit; an explicit waiver can add an
|
|
14
|
+
* opt-out once we have a mechanism for it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { LintFinding, LintRule } from '../types';
|
|
18
|
+
|
|
19
|
+
const RULE_ID = 'extractor-coverage';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Canonical diacritic samples per language. Each must be a single logical
|
|
23
|
+
* word in the target language. Chosen to exercise the specific characters
|
|
24
|
+
* that tend to split under the default ASCII extractor.
|
|
25
|
+
*/
|
|
26
|
+
const DIACRITIC_SAMPLES: Record<string, string> = {
|
|
27
|
+
es: 'añadir', // ñ
|
|
28
|
+
fr: 'déjà', // é, à
|
|
29
|
+
pt: 'coração', // ç, ã
|
|
30
|
+
de: 'größer', // ö, ß
|
|
31
|
+
tr: 'değiştir', // ğ, ş
|
|
32
|
+
it: 'però', // ò
|
|
33
|
+
pl: 'kochać', // ć
|
|
34
|
+
cs: 'příliš', // ř, í
|
|
35
|
+
// Not run for en, ja, ar, ko, zh, ru, hi, etc. — either pure ASCII
|
|
36
|
+
// (en) or non-Latin scripts where the Unicode extractor handles them.
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const extractorCoverageRule: LintRule = input => {
|
|
40
|
+
const findings: LintFinding[] = [];
|
|
41
|
+
|
|
42
|
+
for (const [lang, sample] of Object.entries(DIACRITIC_SAMPLES)) {
|
|
43
|
+
const tokenizer = input.tokenizers[lang];
|
|
44
|
+
if (!tokenizer) continue; // language not registered in this domain
|
|
45
|
+
|
|
46
|
+
const stream = tokenizer.tokenize(sample);
|
|
47
|
+
if (stream.tokens.length !== 1) {
|
|
48
|
+
findings.push({
|
|
49
|
+
rule: RULE_ID,
|
|
50
|
+
severity: 'warning',
|
|
51
|
+
message: `[${lang}] diacritic probe "${sample}" tokenizes as ${stream.tokens.length} tokens — tokenizer likely missing LatinExtendedIdentifierExtractor`,
|
|
52
|
+
context: {
|
|
53
|
+
domain: input.name,
|
|
54
|
+
lang,
|
|
55
|
+
sample,
|
|
56
|
+
tokenCount: stream.tokens.length,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return findings;
|
|
63
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule registry — runs all enabled rules and aggregates findings.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DomainLintInput, LintFinding, LintRule } from '../types';
|
|
6
|
+
import { schemaStructureRule } from './schema-rules';
|
|
7
|
+
import { keywordCoverageRule } from './keyword-coverage';
|
|
8
|
+
import { keywordClassificationRule } from './keyword-classification';
|
|
9
|
+
import { markerTokenizationRule } from './marker-tokenization';
|
|
10
|
+
import { extractorCoverageRule } from './extractor-coverage';
|
|
11
|
+
import { positionOrderingRule } from './position-ordering';
|
|
12
|
+
import { rendererCoherenceRule } from './renderer-coherence';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* All rules in execution order.
|
|
16
|
+
* Phase 1: schema structure (delegates to @lokascript/semantic)
|
|
17
|
+
* Phase 2: keyword coverage + keyword classification + marker tokenization
|
|
18
|
+
* Phase 3: extractor coverage + position ordering (warnings only)
|
|
19
|
+
* Phase 3b: renderer coherence (opt-in via DomainLintInput.renderer)
|
|
20
|
+
*/
|
|
21
|
+
export const ALL_RULES: readonly LintRule[] = [
|
|
22
|
+
schemaStructureRule,
|
|
23
|
+
keywordCoverageRule,
|
|
24
|
+
keywordClassificationRule,
|
|
25
|
+
markerTokenizationRule,
|
|
26
|
+
extractorCoverageRule,
|
|
27
|
+
positionOrderingRule,
|
|
28
|
+
rendererCoherenceRule,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export function runRules(input: DomainLintInput): LintFinding[] {
|
|
32
|
+
const findings: LintFinding[] = [];
|
|
33
|
+
for (const rule of ALL_RULES) {
|
|
34
|
+
findings.push(...rule(input));
|
|
35
|
+
}
|
|
36
|
+
return findings;
|
|
37
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R5: Keyword classification — profile keywords ⊆ tokenizer keyword lists.
|
|
3
|
+
*
|
|
4
|
+
* Every lexical item the parser must recognize as a keyword — profile
|
|
5
|
+
* keyword primaries/alternatives, profile role markers, and schema
|
|
6
|
+
* `markerOverride` words — must actually *classify* as `'keyword'` in that
|
|
7
|
+
* language's tokenizer. This is stricter than R7 (marker-tokenization):
|
|
8
|
+
* a word can tokenize as a single token yet come back as `'identifier'`
|
|
9
|
+
* when the tokenizer's keyword list doesn't contain it.
|
|
10
|
+
*
|
|
11
|
+
* Under the bridge (`buildDomainTokenizer`) profile and tokenizer derive
|
|
12
|
+
* from the same vocab file, so this rule is vacuously green there. It
|
|
13
|
+
* exists for hand-authored tokenizers (e.g. domain-learn) and custom
|
|
14
|
+
* tokenizer overrides, where a vocab/profile edit can silently go stale
|
|
15
|
+
* against the tokenizer's keyword list.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { LintFinding, LintRule } from '../types';
|
|
19
|
+
import { collectLexicalItems } from './marker-tokenization';
|
|
20
|
+
|
|
21
|
+
const RULE_ID = 'keyword-classification';
|
|
22
|
+
|
|
23
|
+
export const keywordClassificationRule: LintRule = input => {
|
|
24
|
+
const findings: LintFinding[] = [];
|
|
25
|
+
|
|
26
|
+
for (const { lang, word, source } of collectLexicalItems(input)) {
|
|
27
|
+
const tokenizer = input.tokenizers[lang];
|
|
28
|
+
if (!tokenizer) continue; // R6 catches unregistered languages.
|
|
29
|
+
|
|
30
|
+
// Same opt-outs as R7: '' means "no marker in this language"; multi-word
|
|
31
|
+
// items can't be a single keyword token and are out of scope.
|
|
32
|
+
if (word === '' || /\s/.test(word)) continue;
|
|
33
|
+
|
|
34
|
+
const kind = tokenizer.classifyToken(word);
|
|
35
|
+
if (kind !== 'keyword' && kind !== 'particle') {
|
|
36
|
+
findings.push({
|
|
37
|
+
rule: RULE_ID,
|
|
38
|
+
severity: 'error',
|
|
39
|
+
message: `"${word}" classifies as '${kind}' (not 'keyword') in ${lang} (${source})`,
|
|
40
|
+
context: {
|
|
41
|
+
domain: input.name,
|
|
42
|
+
lang,
|
|
43
|
+
word,
|
|
44
|
+
source,
|
|
45
|
+
kind,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return findings;
|
|
52
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R6: Keyword coverage across profiles.
|
|
3
|
+
*
|
|
4
|
+
* For every schema action + every registered profile, the profile must have
|
|
5
|
+
* a `keywords[action]` entry. Without it, `createMultilingualDSL()` throws
|
|
6
|
+
* at pattern generation time ("No keyword translation for 'X' in 'Y'").
|
|
7
|
+
*
|
|
8
|
+
* This rule catches the "I added a new schema but only wired it into the
|
|
9
|
+
* English profile" class of drift.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { LintFinding, LintRule } from '../types';
|
|
13
|
+
|
|
14
|
+
const RULE_ID = 'keyword-coverage';
|
|
15
|
+
|
|
16
|
+
export const keywordCoverageRule: LintRule = input => {
|
|
17
|
+
const findings: LintFinding[] = [];
|
|
18
|
+
|
|
19
|
+
for (const profile of input.profiles) {
|
|
20
|
+
for (const schema of input.schemas) {
|
|
21
|
+
const action = schema.action;
|
|
22
|
+
if (!profile.keywords[action]) {
|
|
23
|
+
findings.push({
|
|
24
|
+
rule: RULE_ID,
|
|
25
|
+
severity: 'error',
|
|
26
|
+
message: `profile [${profile.code}] has no keyword for action '${String(action)}'`,
|
|
27
|
+
context: {
|
|
28
|
+
domain: input.name,
|
|
29
|
+
lang: profile.code,
|
|
30
|
+
action: String(action),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return findings;
|
|
38
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R7: Marker and keyword tokenization.
|
|
3
|
+
*
|
|
4
|
+
* Every lexical item the parser needs to match atomically — marker words,
|
|
5
|
+
* profile keyword primaries, and profile keyword alternatives — must
|
|
6
|
+
* tokenize as a single token in its target language. A word that splits
|
|
7
|
+
* across multiple tokens (e.g., `añadir` → `["a", "ñadir"]` when the
|
|
8
|
+
* tokenizer lacks `LatinExtendedIdentifierExtractor`) will never match a
|
|
9
|
+
* pattern's literal token.
|
|
10
|
+
*
|
|
11
|
+
* This is the exact gap that bit us on the Spanish tokenizer mid-session.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { LintFinding, LintRule, DomainLintInput } from '../types';
|
|
15
|
+
import type { LanguageTokenizer } from '../../../../core/types';
|
|
16
|
+
|
|
17
|
+
const RULE_ID = 'marker-tokenization';
|
|
18
|
+
|
|
19
|
+
function tokenCount(tokenizer: LanguageTokenizer, word: string): number {
|
|
20
|
+
const stream = tokenizer.tokenize(word);
|
|
21
|
+
return stream.tokens.length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function collectLexicalItems(input: DomainLintInput): Array<{
|
|
25
|
+
lang: string;
|
|
26
|
+
word: string;
|
|
27
|
+
source: string; // "schema marker <action>.<role>" | "profile keyword <action>.primary" | ...
|
|
28
|
+
}> {
|
|
29
|
+
const items: Array<{ lang: string; word: string; source: string }> = [];
|
|
30
|
+
|
|
31
|
+
// Schema marker overrides (per language)
|
|
32
|
+
for (const schema of input.schemas) {
|
|
33
|
+
for (const role of schema.roles) {
|
|
34
|
+
if (!role.markerOverride) continue;
|
|
35
|
+
for (const [lang, word] of Object.entries(role.markerOverride)) {
|
|
36
|
+
items.push({
|
|
37
|
+
lang,
|
|
38
|
+
word,
|
|
39
|
+
source: `schema markerOverride ${String(schema.action)}.${role.role}`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Profile keywords (primary + alternatives)
|
|
46
|
+
for (const profile of input.profiles) {
|
|
47
|
+
for (const [action, entry] of Object.entries(profile.keywords)) {
|
|
48
|
+
if (entry.primary) {
|
|
49
|
+
items.push({
|
|
50
|
+
lang: profile.code,
|
|
51
|
+
word: entry.primary,
|
|
52
|
+
source: `profile keyword ${action}.primary`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
for (const alt of entry.alternatives ?? []) {
|
|
56
|
+
items.push({
|
|
57
|
+
lang: profile.code,
|
|
58
|
+
word: alt,
|
|
59
|
+
source: `profile keyword ${action}.alternatives`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Profile role markers (primary + alternatives)
|
|
66
|
+
for (const profile of input.profiles) {
|
|
67
|
+
for (const [role, marker] of Object.entries(profile.roleMarkers ?? {})) {
|
|
68
|
+
if (marker.primary) {
|
|
69
|
+
items.push({
|
|
70
|
+
lang: profile.code,
|
|
71
|
+
word: marker.primary,
|
|
72
|
+
source: `profile roleMarker ${role}.primary`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
for (const alt of marker.alternatives ?? []) {
|
|
76
|
+
items.push({
|
|
77
|
+
lang: profile.code,
|
|
78
|
+
word: alt,
|
|
79
|
+
source: `profile roleMarker ${role}.alternatives`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return items;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const markerTokenizationRule: LintRule = input => {
|
|
89
|
+
const findings: LintFinding[] = [];
|
|
90
|
+
const items = collectLexicalItems(input);
|
|
91
|
+
|
|
92
|
+
for (const { lang, word, source } of items) {
|
|
93
|
+
const tokenizer = input.tokenizers[lang];
|
|
94
|
+
if (!tokenizer) {
|
|
95
|
+
// Tokenizer for this language isn't registered with the linter.
|
|
96
|
+
// R6 (keyword-coverage) catches the schema side; nothing to do here.
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Empty-string markers mean "no marker in this language" (an explicit
|
|
101
|
+
// opt-out pattern used by domain-learn to signal that a role is
|
|
102
|
+
// positional in some languages but marker-bearing in others).
|
|
103
|
+
if (word === '') continue;
|
|
104
|
+
|
|
105
|
+
// Whitespace-containing markers (e.g., 'mettre-à-jour' is one word, but
|
|
106
|
+
// 'ask X to Y' wouldn't be) — tokenize the raw word and expect 1 token.
|
|
107
|
+
// Multi-word markers with explicit spaces are out of scope for this rule;
|
|
108
|
+
// skip them rather than flag false positives.
|
|
109
|
+
if (/\s/.test(word)) continue;
|
|
110
|
+
|
|
111
|
+
const count = tokenCount(tokenizer, word);
|
|
112
|
+
if (count !== 1) {
|
|
113
|
+
findings.push({
|
|
114
|
+
rule: RULE_ID,
|
|
115
|
+
severity: 'error',
|
|
116
|
+
message: `"${word}" tokenizes as ${count} tokens in ${lang} (${source})`,
|
|
117
|
+
context: {
|
|
118
|
+
domain: input.name,
|
|
119
|
+
lang,
|
|
120
|
+
word,
|
|
121
|
+
source,
|
|
122
|
+
tokenCount: count,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return findings;
|
|
129
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R9: Role position uniqueness within a schema.
|
|
3
|
+
*
|
|
4
|
+
* `sortRolesByWordOrder` in the pattern generator sorts roles descending by
|
|
5
|
+
* svoPosition or sovPosition (higher value = earlier in surface form). If two
|
|
6
|
+
* roles share the same position value, their relative order is undefined
|
|
7
|
+
* (falls through to array stability) — a latent footgun because token
|
|
8
|
+
* ordering silently changes if someone reorders the roles array.
|
|
9
|
+
*
|
|
10
|
+
* This rule warns when a schema has multiple roles AND any two share an
|
|
11
|
+
* explicit position for the same word order.
|
|
12
|
+
*
|
|
13
|
+
* Warning (not error) because:
|
|
14
|
+
* - Single-role schemas have nothing to order.
|
|
15
|
+
* - A schema might intentionally tie two roles when only one is present
|
|
16
|
+
* at a time (e.g., mutually-exclusive alternatives). In practice we
|
|
17
|
+
* haven't seen this, but the rule should prompt a review, not block.
|
|
18
|
+
*
|
|
19
|
+
* The `sovPosition: 0` default assigned when omitted is NOT flagged — only
|
|
20
|
+
* explicit non-zero collisions count. Otherwise most schemas with 3+
|
|
21
|
+
* optional roles would warn spuriously.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { LintFinding, LintRule } from '../types';
|
|
25
|
+
|
|
26
|
+
const RULE_ID = 'position-ordering';
|
|
27
|
+
|
|
28
|
+
function findCollisions(
|
|
29
|
+
values: Array<{ role: string; position: number }>
|
|
30
|
+
): Array<{ position: number; roles: string[] }> {
|
|
31
|
+
const byPosition = new Map<number, string[]>();
|
|
32
|
+
for (const { role, position } of values) {
|
|
33
|
+
const existing = byPosition.get(position) ?? [];
|
|
34
|
+
existing.push(role);
|
|
35
|
+
byPosition.set(position, existing);
|
|
36
|
+
}
|
|
37
|
+
return Array.from(byPosition.entries())
|
|
38
|
+
.filter(([, roles]) => roles.length > 1)
|
|
39
|
+
.map(([position, roles]) => ({ position, roles }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const positionOrderingRule: LintRule = input => {
|
|
43
|
+
const findings: LintFinding[] = [];
|
|
44
|
+
|
|
45
|
+
for (const schema of input.schemas) {
|
|
46
|
+
if (schema.roles.length < 2) continue;
|
|
47
|
+
|
|
48
|
+
const svoValues = schema.roles
|
|
49
|
+
.filter(r => typeof r.svoPosition === 'number' && r.svoPosition !== 0)
|
|
50
|
+
.map(r => ({ role: r.role, position: r.svoPosition! }));
|
|
51
|
+
const sovValues = schema.roles
|
|
52
|
+
.filter(r => typeof r.sovPosition === 'number' && r.sovPosition !== 0)
|
|
53
|
+
.map(r => ({ role: r.role, position: r.sovPosition! }));
|
|
54
|
+
|
|
55
|
+
for (const { position, roles } of findCollisions(svoValues)) {
|
|
56
|
+
findings.push({
|
|
57
|
+
rule: RULE_ID,
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
message: `[${String(schema.action)}] roles ${roles.join(', ')} share svoPosition=${position} — order is undefined`,
|
|
60
|
+
context: {
|
|
61
|
+
domain: input.name,
|
|
62
|
+
action: String(schema.action),
|
|
63
|
+
wordOrder: 'SVO',
|
|
64
|
+
position,
|
|
65
|
+
roles: roles.join(','),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const { position, roles } of findCollisions(sovValues)) {
|
|
71
|
+
findings.push({
|
|
72
|
+
rule: RULE_ID,
|
|
73
|
+
severity: 'warning',
|
|
74
|
+
message: `[${String(schema.action)}] roles ${roles.join(', ')} share sovPosition=${position} — order is undefined`,
|
|
75
|
+
context: {
|
|
76
|
+
domain: input.name,
|
|
77
|
+
action: String(schema.action),
|
|
78
|
+
wordOrder: 'SOV',
|
|
79
|
+
position,
|
|
80
|
+
roles: roles.join(','),
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return findings;
|
|
87
|
+
};
|