@starci/eslint-canon-fe 8.4.39 → 8.4.41
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 +11 -0
- package/README.md +10 -0
- package/app-shell.mjs +161 -0
- package/callback-shape.mjs +1 -13
- package/docs/app-shell.md +50 -0
- package/docs/grammar-boundary.md +25 -1
- package/docs/surface-rules.md +41 -0
- package/grammar-boundary.mjs +112 -0
- package/hooks-folder.mjs +9 -3
- package/icon.mjs +31 -1
- package/index.mjs +6 -0
- package/lib/app-shell-cluster.mjs +66 -0
- package/lib/app-shell-create.mjs +33 -0
- package/lib/app-shell-owner-source.mjs +124 -0
- package/lib/app-shell-owner.mjs +65 -0
- package/lib/app-shell-scope.mjs +79 -0
- package/lib/app-shell-visible.mjs +92 -0
- package/lib/app-shell.mjs +148 -0
- package/lib/bindings.mjs +15 -0
- package/lib/config.mjs +1 -0
- package/lib/fe-stack.mjs +8 -4
- package/lib/scope.mjs +2 -2
- package/lib/typed-class-values.mjs +72 -0
- package/lib/why.mjs +42 -2
- package/lint-escape-hatch.mjs +2 -2
- package/menu-entries.mjs +133 -0
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +65 -9
- package/runtime/knowledge/hfs/fe-stack.yaml +23 -5
- package/runtime/knowledge/hfs/slots.yaml +113 -53
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/goal/source-phrases.yaml +95 -0
- package/runtime/modules/kernel/removed-vocabulary.yaml +62 -0
- package/runtime/modules/models/registry.yaml +3 -1
- package/runtime/modules/models/runtimes.yaml +2 -0
- package/runtime/scripts/api/git/ls-tree.mjs +5 -0
- package/runtime/scripts/api/git/show.mjs +6 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +10 -10
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +1 -1
- package/runtime/scripts/hfs/architecture/operation-contract-roots.mjs +53 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +1 -1
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +17 -6
- package/runtime/scripts/hfs/architecture/sql-token-forms.mjs +105 -0
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +38 -52
- package/runtime/scripts/hfs/architecture/supabase-keys.mjs +261 -0
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +49 -3
- package/runtime/scripts/hfs/architecture/typescript.mjs +1 -1
- package/runtime/scripts/hfs/declaration-shape.mjs +4 -4
- package/runtime/scripts/hfs/declaration-slots.mjs +14 -3
- package/runtime/scripts/hfs/manifest-shape.mjs +10 -6
- package/runtime/scripts/hfs/rule-params-shape.mjs +18 -6
- package/runtime/scripts/hfs/rules/database-constants.mjs +26 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +489 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +3 -3
- package/runtime/scripts/hfs/slots.mjs +2 -1
- package/runtime/scripts/hfs/view.mjs +2 -1
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +94 -1
- package/runtime/scripts/lib/package-at.mjs +62 -0
- package/runtime/scripts/lib/same-text.mjs +5 -0
- package/runtime/scripts/lib/secret-patterns.mjs +58 -9
- package/runtime/scripts/lib/source-phrases.mjs +34 -0
- package/runtime/scripts/lib/ts-analysis.mjs +10 -0
- package/size-and-state-budget.mjs +3 -3
- package/surface-rules.mjs +230 -0
- package/swr-family.mjs +136 -7
- package/transport.mjs +2 -1
|
@@ -15,7 +15,7 @@ export const SECOND_LANGUAGE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìí
|
|
|
15
15
|
export const hasSecondLanguage = (text) => typeof text === 'string' && SECOND_LANGUAGE_LETTER.test(text.normalize('NFC'));
|
|
16
16
|
|
|
17
17
|
/** Every line of `text` with a Vietnamese letter: `[{ line, column }]`, both 1-based. */
|
|
18
|
-
|
|
18
|
+
function secondLanguageHits(text) {
|
|
19
19
|
const hits = [];
|
|
20
20
|
String(text).split(/\r?\n/).forEach((raw, index) => {
|
|
21
21
|
const match = SECOND_LANGUAGE_LETTER.exec(raw.normalize('NFC'));
|
|
@@ -24,6 +24,99 @@ export function secondLanguageHits(text) {
|
|
|
24
24
|
return hits;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** The named bindings of an import from the backend message catalog contract. */
|
|
28
|
+
function messageCatalogBindings(ts, statement) {
|
|
29
|
+
if (!ts.isImportDeclaration(statement) || statement.moduleSpecifier.text !== '@modules/platform/i18n') return null;
|
|
30
|
+
return statement.importClause?.namedBindings ?? null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The local type names imported from the backend message catalog contract. */
|
|
34
|
+
function messageBundleImports(ts, source) {
|
|
35
|
+
const named = new Set();
|
|
36
|
+
const namespaces = new Set();
|
|
37
|
+
for (const statement of source.statements) {
|
|
38
|
+
const bindings = messageCatalogBindings(ts, statement);
|
|
39
|
+
if (!bindings) continue;
|
|
40
|
+
if (ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
|
|
41
|
+
for (const entry of ts.isNamedImports(bindings) ? bindings.elements : []) {
|
|
42
|
+
if ((entry.propertyName ?? entry.name).text === 'MessageBundle') named.add(entry.name.text);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { named, namespaces };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A declaration that introduces a same-named local type or namespace. */
|
|
49
|
+
function declaresTypeName(ts, statement, name) {
|
|
50
|
+
if (statement.name?.text !== name) return false;
|
|
51
|
+
return [ts.isTypeAliasDeclaration, ts.isInterfaceDeclaration, ts.isClassDeclaration, ts.isEnumDeclaration, ts.isModuleDeclaration]
|
|
52
|
+
.some(predicate => predicate(statement));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A type reference bound to the imported MessageBundle contract, including import aliases. */
|
|
56
|
+
function isMessageBundleType(ts, type, imports) {
|
|
57
|
+
if (!type || !ts.isTypeReferenceNode(type)) return false;
|
|
58
|
+
const name = type.typeName;
|
|
59
|
+
if (ts.isIdentifier(name)) return imports.named.has(name.text) && !shadowsImportedType(ts, type, name.text);
|
|
60
|
+
return ts.isQualifiedName(name) && ts.isIdentifier(name.left)
|
|
61
|
+
&& imports.namespaces.has(name.left.text) && name.right.text === 'MessageBundle' && !shadowsImportedType(ts, type, name.left.text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A local declaration or generic type parameter takes precedence over a same-named import. */
|
|
65
|
+
function shadowsImportedType(ts, node, name) {
|
|
66
|
+
for (let scope = node.parent; scope; scope = scope.parent) {
|
|
67
|
+
if (scope.typeParameters?.some(parameter => parameter.name.text === name)) return true;
|
|
68
|
+
if (scope.statements?.some(statement => declaresTypeName(ts, statement, name))) return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The literal object supplied by a variable typed with the imported MessageBundle contract. */
|
|
74
|
+
function messageCatalogObject(ts, node, imports) {
|
|
75
|
+
if (!ts.isVariableDeclaration(node) || !node.initializer) return null;
|
|
76
|
+
let value = node.initializer;
|
|
77
|
+
const type = ts.isSatisfiesExpression(value) ? value.type : node.type;
|
|
78
|
+
if (ts.isSatisfiesExpression(value)) value = value.expression;
|
|
79
|
+
return isMessageBundleType(ts, type, imports) && ts.isObjectLiteralExpression(value) ? value : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Literal string values in the Vietnamese locale of one message catalog. */
|
|
83
|
+
function vietnameseValueRanges(ts, catalog, source) {
|
|
84
|
+
const ranges = [];
|
|
85
|
+
for (const locale of catalog.properties) {
|
|
86
|
+
if (!ts.isPropertyAssignment(locale) || locale.name.text !== 'vi' || !ts.isObjectLiteralExpression(locale.initializer)) continue;
|
|
87
|
+
for (const message of locale.initializer.properties) {
|
|
88
|
+
if (ts.isPropertyAssignment(message) && ts.isStringLiteralLike(message.initializer))
|
|
89
|
+
ranges.push({ start: message.initializer.getStart(source), end: message.initializer.end });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return ranges;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Literal Vietnamese message values of typed catalogs; keys, comments, English values and expressions remain source. */
|
|
96
|
+
function messageBundleValueRanges(ts, source) {
|
|
97
|
+
const imports = messageBundleImports(ts, source);
|
|
98
|
+
const ranges = [];
|
|
99
|
+
const visit = (node) => {
|
|
100
|
+
const catalog = messageCatalogObject(ts, node, imports);
|
|
101
|
+
if (catalog) ranges.push(...vietnameseValueRanges(ts, catalog, source));
|
|
102
|
+
ts.forEachChild(node, visit);
|
|
103
|
+
};
|
|
104
|
+
visit(source);
|
|
105
|
+
return ranges;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Source-language hits, treating only literal vi values of typed backend MessageBundle objects as translated product data. */
|
|
109
|
+
export function sourceLanguageHits(rel, text, ts) {
|
|
110
|
+
if (!hasSecondLanguage(text)) return [];
|
|
111
|
+
if (!ts || !/\.[cm]?tsx?$/.test(rel)) return secondLanguageHits(text);
|
|
112
|
+
const source = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true);
|
|
113
|
+
let sourceText = text;
|
|
114
|
+
for (const range of messageBundleValueRanges(ts, source).sort((left, right) => right.start - left.start)) {
|
|
115
|
+
sourceText = sourceText.slice(0, range.start) + sourceText.slice(range.start, range.end).replace(/[^\r\n]/g, ' ') + sourceText.slice(range.end);
|
|
116
|
+
}
|
|
117
|
+
return secondLanguageHits(sourceText);
|
|
118
|
+
}
|
|
119
|
+
|
|
27
120
|
/**
|
|
28
121
|
* The Vietnamese fields of one failure-code entry: the operator text the owner mandated for the failure-code catalog
|
|
29
122
|
* (scripts/checks/check-failure-codes.mjs types the entry with exactly these). causes_vi is the list companion of the three scalar fields.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// scripts/lib/package-at.mjs — a package the runtime does not ship (playwright, esbuild, tailwindcss) comes from the
|
|
2
|
+
// project that owns it: node resolution from that project's directory, walking up its node_modules.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
7
|
+
|
|
8
|
+
/** The package.json of `name` as resolved from `dir`, or null. */
|
|
9
|
+
export const packageAt = (dir, name) => {
|
|
10
|
+
try { return createRequire(path.join(dir, 'package.json')).resolve(`${name}/package.json`); } catch { return null; }
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** This runtime's own `starci` bin (packages/cli is the only package that declares one). */
|
|
14
|
+
const RUNTIME_STARCI_BIN = path.join(skillRoot, 'packages', 'cli', 'bin', 'starci.mjs');
|
|
15
|
+
|
|
16
|
+
/** The `starci` bin of the @starci/cli that `dir` installs (a product runs its own pinned CLI), else this runtime's own. */
|
|
17
|
+
function starciBin(dir) {
|
|
18
|
+
const packageFile = packageAt(dir, '@starci/cli');
|
|
19
|
+
if (packageFile) {
|
|
20
|
+
try {
|
|
21
|
+
const { bin } = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
|
|
22
|
+
const rel = typeof bin === 'string' ? bin : bin?.starci;
|
|
23
|
+
const file = rel ? path.join(path.dirname(packageFile), rel) : null;
|
|
24
|
+
if (file && fs.existsSync(file)) return file;
|
|
25
|
+
} catch { /* an unreadable install falls back to the runtime's own CLI */ }
|
|
26
|
+
}
|
|
27
|
+
return RUNTIME_STARCI_BIN;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The first of `names` resolvable from the first of `dirs` that has one: {name, root, packageFile, version} or null. */
|
|
31
|
+
export function findPackage(dirs, names) {
|
|
32
|
+
for (const dir of dirs.filter(Boolean)) {
|
|
33
|
+
for (const name of names) {
|
|
34
|
+
const packageFile = packageAt(dir, name);
|
|
35
|
+
if (!packageFile) continue;
|
|
36
|
+
const require = createRequire(packageFile);
|
|
37
|
+
return { name, root: path.dirname(packageFile), packageFile, version: require(packageFile).version };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The CommonJS entry of a package found by findPackage. */
|
|
44
|
+
export const requirePackage = (found) => createRequire(found.packageFile)(found.name);
|
|
45
|
+
|
|
46
|
+
/** The TypeScript compiler resolvable from the first of `dirs` (an app root, then the caller's own), or null. */
|
|
47
|
+
export const loadTypescript = (...dirs) => {
|
|
48
|
+
const located = findPackage(dirs, ['typescript']);
|
|
49
|
+
return located ? requirePackage(located) : null;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The app's command line and the app implementation behind it: `bin` the `starci` bin of the app's installed @starci/cli, else
|
|
54
|
+
* this runtime's packages/cli/bin/starci.mjs (starciBin); `dir` the @starci/hfs that answers
|
|
55
|
+
* `starci app` - the app's own install, else the one beside its @starci/cli, else this runtime's packages/hfs - whose runtime
|
|
56
|
+
* slice the gate's base lint (scripts/gates/gate.mjs) and the READ digest (scripts/gates/read-digest.mjs) load in-process.
|
|
57
|
+
*/
|
|
58
|
+
export function hfsEntry(root) {
|
|
59
|
+
const cli = packageAt(root, '@starci/cli');
|
|
60
|
+
const manifest = packageAt(root, '@starci/hfs') ?? (cli && packageAt(path.dirname(cli), '@starci/hfs'));
|
|
61
|
+
return { dir: manifest ? path.dirname(manifest) : path.join(skillRoot, 'packages', 'hfs'), bin: starciBin(root) };
|
|
62
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// same-text.mjs - text equality that ignores the line-ending form and a trailing blank tail (a git runner may trim stdout, a work tree may
|
|
2
|
+
// carry CRLF): the one comparison the database rules use for a committed file against its base or against a regenerated one.
|
|
3
|
+
|
|
4
|
+
/** True when both texts exist and are equal once CRLF is folded to LF and trailing whitespace at the end is dropped. */
|
|
5
|
+
export const sameText = (a, b) => a !== null && b !== null && a.replaceAll('\r\n', '\n').trimEnd() === b.replaceAll('\r\n', '\n').trimEnd();
|
|
@@ -56,6 +56,45 @@ const ASSIGNED_UNQUOTED = new RegExp(
|
|
|
56
56
|
SECRET_NAME + String.raw`(?!['"\`]|env\()([A-Za-z0-9_+=-]{12,})(?![A-Za-z0-9_+=.(\[-])`,
|
|
57
57
|
);
|
|
58
58
|
|
|
59
|
+
const SQL_ROLE_HEAD = /^\s*(?:CREATE|ALTER)\s+(?:ROLE|USER)\b/i;
|
|
60
|
+
const SQL_STATEMENT = /[^;\r\n]+/g;
|
|
61
|
+
const SQL_PASSWORD_WORD = /\bPASSWORD\s+/gi;
|
|
62
|
+
const PSQL_PASSWORD_REFERENCE = /:(?:'[A-Za-z_]\w*'|"[A-Za-z_]\w*")/y;
|
|
63
|
+
const SQL_PASSWORD_QUOTED = /'([^'\r\n]{12,})'/y;
|
|
64
|
+
const ASSIGNED_PLACEHOLDER = /fixture|stub|fake|dummy|placeholder|example|sample|changeme|redacted|mock/i;
|
|
65
|
+
const SQL_PASSWORD_RULE = { name: 'assigned-secret' };
|
|
66
|
+
|
|
67
|
+
/** Read only password tokens in role statements; sticky value readers never retry at later character positions. */
|
|
68
|
+
function sqlPasswordParts(file, text) {
|
|
69
|
+
const references = [], literals = [];
|
|
70
|
+
if (!/\.(?:sql|sh)(?:\.tpl)?$/i.test(file ?? '')) return { references, literals };
|
|
71
|
+
for (const statement of text.matchAll(SQL_STATEMENT)) {
|
|
72
|
+
if (!SQL_ROLE_HEAD.test(statement[0])) continue;
|
|
73
|
+
for (const keyword of statement[0].matchAll(SQL_PASSWORD_WORD)) {
|
|
74
|
+
const start = statement.index + keyword.index + keyword[0].length;
|
|
75
|
+
PSQL_PASSWORD_REFERENCE.lastIndex = start;
|
|
76
|
+
SQL_PASSWORD_QUOTED.lastIndex = start;
|
|
77
|
+
const reference = PSQL_PASSWORD_REFERENCE.exec(text);
|
|
78
|
+
const literal = SQL_PASSWORD_QUOTED.exec(text);
|
|
79
|
+
if (reference) references.push([start, start + reference[0].length]);
|
|
80
|
+
if (literal) literals.push(literal[1]);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { references, literals };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Psql variable references retain their positions while the assignment heuristic sees no literal value there. */
|
|
87
|
+
function maskedReferences(text, references) {
|
|
88
|
+
const parts = [];
|
|
89
|
+
let cursor = 0;
|
|
90
|
+
for (const [start, end] of references) {
|
|
91
|
+
parts.push(text.slice(cursor, start), ' '.repeat(end - start));
|
|
92
|
+
cursor = end;
|
|
93
|
+
}
|
|
94
|
+
parts.push(text.slice(cursor));
|
|
95
|
+
return parts.join('');
|
|
96
|
+
}
|
|
97
|
+
|
|
59
98
|
const NAME_WORD = (word, tail) => new RegExp(`^${NAME_REST}${SEGMENT_BEFORE}(?:${word})${tail}`);
|
|
60
99
|
const SECRET_NAME_TEST = NAME_WORD(SECRET_WORD, String.raw`(?:[_\-.]|[A-Z]|[^A-Za-z0-9_$]|$)`);
|
|
61
100
|
const BENIGN_TAIL_TEST = NAME_WORD(BENIGN_WORD, '$');
|
|
@@ -84,27 +123,37 @@ export const SECRET_PATTERNS = [
|
|
|
84
123
|
// A spec file's keyword-assigned values are test inputs (a spec's sign-in
|
|
85
124
|
// form password and a mocked accessToken once refused a push): the heuristic skips spec files too.
|
|
86
125
|
// A SCREAMING_SNAKE quoted value in source is a public code (an enum member whose value is an upper-case code), not a credential.
|
|
87
|
-
{ name: 'assigned-secret', re: ASSIGNED_QUOTED, sourceCode: /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/, placeholder:
|
|
126
|
+
{ name: 'assigned-secret', re: ASSIGNED_QUOTED, sourceCode: /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/, placeholder: ASSIGNED_PLACEHOLDER,
|
|
88
127
|
skipFile: /\.(?:spec|test|e2e-spec)\.[cm]?[jt]sx?$/ },
|
|
89
128
|
// An unquoted value in TypeScript or JavaScript source is an identifier or a type (`token: InjectionToken`,
|
|
90
129
|
// `MODULE_OPTIONS_TOKEN: DATABASE_OPTIONS_TOKEN`), never a literal; the unquoted form is the dotenv, compose and toml shape.
|
|
91
|
-
{ name: 'assigned-secret', re: ASSIGNED_UNQUOTED, placeholder:
|
|
130
|
+
{ name: 'assigned-secret', re: ASSIGNED_UNQUOTED, placeholder: ASSIGNED_PLACEHOLDER,
|
|
92
131
|
skipFile: /\.(?:spec|test|e2e-spec)\.[cm]?[jt]sx?$|\.[cm]?[jt]sx?$/ },
|
|
93
132
|
];
|
|
94
133
|
|
|
95
134
|
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
const
|
|
135
|
+
/** Matched pattern metadata stays private; callers receive pattern names, never credential values. */
|
|
136
|
+
function matchingSecretPatterns(file, text) {
|
|
137
|
+
const matches = [];
|
|
138
|
+
const sql = sqlPasswordParts(file, text);
|
|
139
|
+
const assignments = maskedReferences(text, sql.references);
|
|
99
140
|
for (const rule of SECRET_PATTERNS) {
|
|
100
141
|
if (rule.skipFile?.test(file ?? '')) continue;
|
|
101
|
-
const hit = rule.re.exec(text);
|
|
102
|
-
if (hit && rule.
|
|
103
|
-
if (hit && !rule.placeholder?.test(hit[1] ?? '')) names.push(rule.name);
|
|
142
|
+
const hit = rule.re.exec(rule.name === 'assigned-secret' ? assignments : text);
|
|
143
|
+
if (hit && !rule.placeholder?.test(hit[1] ?? '')) matches.push({ rule, value: hit[1] ?? '' });
|
|
104
144
|
}
|
|
105
|
-
|
|
145
|
+
for (const value of sql.literals) if (!ASSIGNED_PLACEHOLDER.test(value)) matches.push({ rule: SQL_PASSWORD_RULE, value });
|
|
146
|
+
return matches;
|
|
106
147
|
}
|
|
107
148
|
|
|
149
|
+
/** The strict secret-shape judgement used by Work custody, including uppercase keyword-assigned credentials. */
|
|
150
|
+
export const secretPatternHits = (file, text) => matchingSecretPatterns(file, text).map(({ rule }) => rule.name);
|
|
151
|
+
|
|
152
|
+
/** The source judgement also admits public SCREAMING_SNAKE codes; every provider shape remains refused. */
|
|
153
|
+
export const secretHits = (file, text) => matchingSecretPatterns(file, text)
|
|
154
|
+
.filter(({ rule, value }) => !(rule.sourceCode?.test(value) && /\.[cm]?[jt]sx?$/.test(file ?? '')))
|
|
155
|
+
.map(({ rule }) => rule.name);
|
|
156
|
+
|
|
108
157
|
const BADGE_BEGIN = '<!-- >>> starci app sync (managed block; do not edit) >>> -->';
|
|
109
158
|
const BADGE_END = '<!-- <<< starci app sync <<< -->';
|
|
110
159
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// source-phrases.mjs - the Vietnamese phrase lists runtime source matches against owner or product text are DATA,
|
|
2
|
+
// not source: they live in modules/goal/source-phrases.yaml (its leaf keys are declared Vietnamese fields,
|
|
3
|
+
// scripts/lib/language.mjs DECLARED_VIETNAMESE_FIELDS - like the archetypes.yaml phrase lists) so the matchers'
|
|
4
|
+
// source stays English. A list is plain words or regex alternatives, per its comment in the lexicon.
|
|
5
|
+
//
|
|
6
|
+
// phrasesOf('owed.ownerWord') -> the list as written (['<owner word>', ...])
|
|
7
|
+
// altOf('owed.ownerWord') -> the regex alternation 'a|b|c'; '(?!)' - never matches - when absent
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { skillRoot } from '../../engine/runtime-root.mjs';
|
|
11
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
12
|
+
|
|
13
|
+
const SOURCE_PHRASES_FILE = 'modules/goal/source-phrases.yaml';
|
|
14
|
+
|
|
15
|
+
const cache = new Map();
|
|
16
|
+
|
|
17
|
+
/** The lexicon's `phrases` map ({<group>: {<list>: string[]}}), cached per root. */
|
|
18
|
+
function sourcePhrases(root = skillRoot) {
|
|
19
|
+
if (!cache.has(root)) {
|
|
20
|
+
let doc = null;
|
|
21
|
+
try { doc = parseYaml(fs.readFileSync(path.join(root, ...SOURCE_PHRASES_FILE.split('/')), 'utf8')); } catch { doc = null; }
|
|
22
|
+
cache.set(root, doc?.phrases && typeof doc.phrases === 'object' ? doc.phrases : {});
|
|
23
|
+
}
|
|
24
|
+
return cache.get(root);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The phrase list `dotKey` (`<group>.<list>`) of `root`, [] when absent. */
|
|
28
|
+
export function phrasesOf(dotKey, root = skillRoot) {
|
|
29
|
+
const value = dotKey.split('.').reduce((v, k) => (v && typeof v === 'object' ? v[k] : undefined), sourcePhrases(root));
|
|
30
|
+
return (Array.isArray(value) ? value : []).map(String);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The regex alternation of `dotKey` ('a|b|c'); '(?!)' - a never-match - when the list is empty. */
|
|
34
|
+
export const altOf = (dotKey, root = skillRoot) => phrasesOf(dotKey, root).join('|') || '(?!)';
|
|
@@ -25,3 +25,13 @@ export function importsOf(ts, sourceFile, { moduleKey = 'module', stringLiterals
|
|
|
25
25
|
|
|
26
26
|
/** Catalog values unwrap parentheses, `as`, and `satisfies`, preserving assertions and non-null nodes. */
|
|
27
27
|
export const unwrapCatalogExpression = (ts, node) => unwrapExpression(ts, node, { typeAssertions: false, nonNullExpressions: false });
|
|
28
|
+
|
|
29
|
+
/** Bind this file in isolation: the compiler resolves lexical names, but imports and library files are never loaded. */
|
|
30
|
+
export function lexicalChecker(ts, source) {
|
|
31
|
+
const options = { noLib: true, noResolve: true, allowJs: true };
|
|
32
|
+
const host = ts.createCompilerHost(options);
|
|
33
|
+
host.getSourceFile = (file) => file === source.fileName ? source : undefined;
|
|
34
|
+
host.fileExists = () => false;
|
|
35
|
+
host.readFile = () => undefined;
|
|
36
|
+
return ts.createProgram([source.fileName], options, host).getTypeChecker();
|
|
37
|
+
}
|
|
@@ -205,9 +205,9 @@ const readsData = (context, fn, seen = new Set()) => {
|
|
|
205
205
|
* No hand-written polling loop in a component or hook.
|
|
206
206
|
*
|
|
207
207
|
* A timer is polling when what it runs READS DATA (see `readsData`, decided by the type of each call, not by a name): a
|
|
208
|
-
* `setInterval`, or a `setTimeout` whose callback schedules the enclosing function again.
|
|
209
|
-
*
|
|
210
|
-
*
|
|
208
|
+
* `setInterval`, or a `setTimeout` whose callback schedules the enclosing function again. A timer that only ticks time or state
|
|
209
|
+
* is not a data-refresh mechanism. Grammar's live-time ownership is independently enforced by `no-app-relative-clock` in
|
|
210
|
+
* `grammar-boundary.mjs`.
|
|
211
211
|
*/
|
|
212
212
|
export const noHandRolledPolling = {
|
|
213
213
|
meta: {
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/** Grammar owns surface nesting and the single content edge of a panel list (ANATOMY-2, PADDING-0). */
|
|
2
|
+
import { attributeValue, stringOf } from "./lib/ast.mjs"
|
|
3
|
+
import { importOf, isFn, patternPath, unwrap, variableOf } from "./lib/bindings.mjs"
|
|
4
|
+
import { typedClassStrings, typedPropertyStrings } from "./lib/typed-class-values.mjs"
|
|
5
|
+
import { reactHookOf } from "./lib/react.mjs"
|
|
6
|
+
|
|
7
|
+
const GRAMMAR_ENTRY = /^@starci\/grammar(?:\/(?:common|core|heritage|offset-pop))?$/
|
|
8
|
+
const SURFACES = new Set([
|
|
9
|
+
"SurfaceCard", "SurfaceListCard", "SurfaceAccordionCard", "Rail", "Sidebar",
|
|
10
|
+
"Dialog", "Drawer", "AlertDialog", "Popover",
|
|
11
|
+
])
|
|
12
|
+
const FIELDS = new Set([
|
|
13
|
+
"Input", "TextField", "Textarea", "TextArea", "SearchField", "NumberField", "DateField", "TimeField",
|
|
14
|
+
"Select", "ComboBox", "DatePicker", "DateRangePicker", "Checkbox", "CheckboxGroup", "RadioGroup", "PressableField", "OtpInput",
|
|
15
|
+
])
|
|
16
|
+
const CLASS_BUILDERS = new Set(["cn", "clsx", "classNames", "cva", "tv"])
|
|
17
|
+
|
|
18
|
+
/** Resolve a JSX name to its Grammar export and optional compound parts, preserving lexical import identity. */
|
|
19
|
+
const grammarName = (context, name) => {
|
|
20
|
+
if (name.type === "JSXMemberExpression") {
|
|
21
|
+
const owner = grammarName(context, name.object)
|
|
22
|
+
return owner === null ? null : [...owner, name.property.name]
|
|
23
|
+
}
|
|
24
|
+
if (name.type !== "JSXIdentifier") return null
|
|
25
|
+
const imported = importOf(context, name)
|
|
26
|
+
if (!imported || !GRAMMAR_ENTRY.test(imported.source)) return null
|
|
27
|
+
if (imported.imported !== null) return [imported.imported]
|
|
28
|
+
const binding = variableOf(context, name)?.defs.find((definition) => definition.type === "ImportBinding")
|
|
29
|
+
return binding?.node.type === "ImportNamespaceSpecifier" ? [] : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Class and variant expressions expose their statically authored strings, including const aliases and cn branches. */
|
|
33
|
+
const authoredStrings = (context, input, seen = new Set(), mode = "classes") => {
|
|
34
|
+
const node = unwrap(input?.type === "JSXExpressionContainer" ? input.expression : input)
|
|
35
|
+
if (!node || seen.has(node)) return []
|
|
36
|
+
seen.add(node)
|
|
37
|
+
const text = stringOf(node)
|
|
38
|
+
if (text !== null) return [{ node, text }]
|
|
39
|
+
if (node.type === "TemplateElement") return [{ node, text: node.value.cooked ?? node.value.raw }]
|
|
40
|
+
if (node.type === "Identifier") {
|
|
41
|
+
const values = bindingValues(context, node)
|
|
42
|
+
return values.length ? values.flatMap((value) => authoredStrings(context, value, seen, mode)) : typedClassStrings(context, node, mode)
|
|
43
|
+
}
|
|
44
|
+
if (node.type === "MemberExpression") {
|
|
45
|
+
const propertyName = node.computed ? stringOf(node.property) : node.property.name
|
|
46
|
+
const values = propertyValues(context, node.object, propertyName)
|
|
47
|
+
return values.length ? values.flatMap((value) => authoredStrings(context, value, seen, mode)) : typedClassStrings(context, node, mode)
|
|
48
|
+
}
|
|
49
|
+
const children = stringChildren(node, mode)
|
|
50
|
+
return children.flatMap((child) => authoredStrings(context, child, seen, mode))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Only value-bearing positions contribute classes; object keys support cn's conditional class dictionary. */
|
|
54
|
+
const stringChildren = (node, mode) => {
|
|
55
|
+
if (node.type === "TemplateLiteral") return mode === "classes" ? [...node.quasis, ...node.expressions] : []
|
|
56
|
+
if (node.type === "ArrayExpression") return node.elements
|
|
57
|
+
if (node.type === "CallExpression") return mode === "classes" ? node.arguments : []
|
|
58
|
+
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate]
|
|
59
|
+
if (node.type === "LogicalExpression") return node.operator === "&&" ? [node.right] : [node.left, node.right]
|
|
60
|
+
if (node.type === "ObjectExpression") return node.properties.flatMap((property) => property.type === "Property" ? [property.key, property.value] : [property.argument])
|
|
61
|
+
return []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A declared React component is a separate render tree; inline collection/render callbacks keep the surrounding context. */
|
|
65
|
+
const componentBoundary = (context, node) => {
|
|
66
|
+
if (!isFn(node)) return false
|
|
67
|
+
if (node.parent?.type === "CallExpression" && ["memo", "forwardRef"].includes(reactHookOf(context, node.parent))) return true
|
|
68
|
+
if (node.parent?.type === "JSXExpressionContainer" || node.parent?.type === "CallExpression" && node.parent.arguments.includes(node)) return false
|
|
69
|
+
const name = node.id?.name ?? (node.parent?.type === "VariableDeclarator" ? node.parent.id.name : null)
|
|
70
|
+
return name !== null && /^[A-Z]/.test(name)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The Grammar ancestors whose rendered content contains this node, excluding an overlay's trigger. */
|
|
74
|
+
function* surfaceAncestors(context, node) {
|
|
75
|
+
const triggers = new Set()
|
|
76
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
77
|
+
if (componentBoundary(context, current)) return
|
|
78
|
+
if (current.type === "JSXAttribute" && current.name.name === "trigger") triggers.add(current.parent)
|
|
79
|
+
if (current.type === "JSXElement" && !triggers.has(current.openingElement)) yield current.openingElement
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isSurface = (context, node, descendant) => {
|
|
84
|
+
const name = grammarName(context, node.name)
|
|
85
|
+
if (name?.length !== 1) return false
|
|
86
|
+
if (SURFACES.has(name[0])) return true
|
|
87
|
+
if (name[0] !== "ChatWorkspace") return false
|
|
88
|
+
for (let current = descendant; current && current !== node.parent; current = current.parent) {
|
|
89
|
+
if (current.type === "JSXAttribute" && current.parent === node && current.name.name === "rail") return true
|
|
90
|
+
}
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
const classTokens = (text) => text.split(/\s+/).map((token) => token.split(":").at(-1).replace(/^!|!$/g, ""))
|
|
94
|
+
const rawSurface = (token) => /^bg-surface(?:-[a-z0-9-]+)?(?:\/[^\s]+)?$/.test(token)
|
|
95
|
+
const horizontalPadding = (token) => /^(?:px|ps|pe)-.+$/.test(token)
|
|
96
|
+
|
|
97
|
+
/** Computed keys select their evaluated static name, rather than the identifier's spelling. */
|
|
98
|
+
const propertyName = (context, property) => {
|
|
99
|
+
if (!property.computed) return property.key.name ?? property.key.value
|
|
100
|
+
const values = authoredStrings(context, property.key, new Set(), "variant")
|
|
101
|
+
return values.length === 1 ? values[0].text : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A binding selects its own object property; sibling declarations do not become its value. */
|
|
105
|
+
const bindingValues = (context, node, seen = new Set()) => {
|
|
106
|
+
const definition = variableOf(context, node)?.defs.find((entry) => entry.type === "Variable")
|
|
107
|
+
if (!definition) return []
|
|
108
|
+
const path = patternPath(definition.node.id, node.name)
|
|
109
|
+
if (path === null) return []
|
|
110
|
+
return path.reduce((values, key) => values.flatMap((value) => propertyValues(context, value, key, seen)), [definition.node.init]).filter(Boolean)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Static object spreads retain the same published-prop obligations as ordinary JSX attributes. */
|
|
114
|
+
const propertyValues = (context, input, key, seen = new Set()) => {
|
|
115
|
+
const node = unwrap(input)
|
|
116
|
+
if (!node || seen.has(node)) return []
|
|
117
|
+
seen.add(node)
|
|
118
|
+
if (node.type === "Identifier") {
|
|
119
|
+
return bindingValues(context, node, seen).flatMap((value) => propertyValues(context, value, key, seen))
|
|
120
|
+
}
|
|
121
|
+
if (node.type === "MemberExpression") {
|
|
122
|
+
const member = node.computed ? stringOf(node.property) : node.property.name
|
|
123
|
+
return propertyValues(context, node.object, member, seen).flatMap((value) => propertyValues(context, value, key, seen))
|
|
124
|
+
}
|
|
125
|
+
if (node.type === "ConditionalExpression") return [node.consequent, node.alternate].flatMap((branch) => propertyValues(context, branch, key, seen))
|
|
126
|
+
if (node.type !== "ObjectExpression") return []
|
|
127
|
+
return node.properties.flatMap((property) => {
|
|
128
|
+
if (property.type === "SpreadElement") return propertyValues(context, property.argument, key, seen)
|
|
129
|
+
return propertyName(context, property) === key ? [property.value] : []
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const propStrings = (context, entry, name, mode = "classes") => {
|
|
134
|
+
if (entry.type === "JSXSpreadAttribute") {
|
|
135
|
+
const values = propertyValues(context, entry.argument, name)
|
|
136
|
+
return values.length ? values.flatMap((value) => authoredStrings(context, value, new Set(), mode)) : typedPropertyStrings(context, entry.argument, name, mode)
|
|
137
|
+
}
|
|
138
|
+
return entry.name.name === name ? authoredStrings(context, attributeValue(entry), new Set(), mode) : []
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** An explicit section caption excludes the row's own content subtree. */
|
|
142
|
+
const inSectionCaption = (context, node) => {
|
|
143
|
+
for (const ancestor of surfaceAncestors(context, node.parent)) {
|
|
144
|
+
const owner = grammarName(context, ancestor.name)
|
|
145
|
+
if (owner?.[0] !== "ListBox") continue
|
|
146
|
+
if (owner[1] === "Item") return false
|
|
147
|
+
if (["Section", "Header", "SectionHeader"].includes(owner[1])) return true
|
|
148
|
+
}
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** ANATOMY-2: Grammar provides the secondary default; apps cannot force primary or paint a raw surface. */
|
|
153
|
+
export const nestedControlVariant = {
|
|
154
|
+
meta: {
|
|
155
|
+
type: "problem",
|
|
156
|
+
docs: { description: "ANATOMY-2: nested fields use Grammar's secondary default and surfaces belong to Grammar." },
|
|
157
|
+
schema: [],
|
|
158
|
+
messages: {
|
|
159
|
+
primary: "FE_NESTED_VARIANT: a field inside a Grammar surface cannot force variant=\"primary\". Omit variant so Grammar supplies secondary through its nesting context.",
|
|
160
|
+
surface: "FE_NESTED_VARIANT: bg-surface paints a surface in app CSS. Compose SurfaceCard, Rail, Sidebar or another Grammar surface instead.",
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
create(context) {
|
|
164
|
+
const reported = new Set()
|
|
165
|
+
const checkClasses = (value) => {
|
|
166
|
+
for (const entry of authoredStrings(context, value)) {
|
|
167
|
+
if (!classTokens(entry.text).some(rawSurface) || reported.has(entry.node)) continue
|
|
168
|
+
reported.add(entry.node)
|
|
169
|
+
context.report({ node: entry.node, messageId: "surface" })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
JSXOpeningElement(node) {
|
|
174
|
+
const name = grammarName(context, node.name)
|
|
175
|
+
const variant = node.attributes.find((entry) => propStrings(context, entry, "variant", "variant").some((value) => value.text === "primary"))
|
|
176
|
+
if (name?.length !== 1 || !FIELDS.has(name[0]) || !variant) return
|
|
177
|
+
if ([...surfaceAncestors(context, node.parent)].some((ancestor) => isSurface(context, ancestor, node))) context.report({ node: variant, messageId: "primary" })
|
|
178
|
+
},
|
|
179
|
+
JSXAttribute(node) {
|
|
180
|
+
if (["className", "class", "classNames"].includes(node.name.name)) checkClasses(node.value)
|
|
181
|
+
},
|
|
182
|
+
CallExpression(node) {
|
|
183
|
+
if (node.callee.type === "Identifier" && CLASS_BUILDERS.has(node.callee.name)) checkClasses(node)
|
|
184
|
+
},
|
|
185
|
+
VariableDeclarator(node) {
|
|
186
|
+
if (/[Cc]lass/.test(node.id.name ?? "") || /(?:^|[/\\])classNames\.[cm]?[jt]sx?$/.test(context.filename)) checkClasses(node.init)
|
|
187
|
+
},
|
|
188
|
+
Property(node) {
|
|
189
|
+
if (["className", "class", "classNames"].includes(propertyName(context, node))) checkClasses(node.value)
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** PADDING-0: app padding wrappers cannot add a second horizontal inset to Grammar's list or section caption. */
|
|
196
|
+
export const noListPanelPadding = {
|
|
197
|
+
meta: {
|
|
198
|
+
type: "problem",
|
|
199
|
+
docs: { description: "PADDING-0: panel lists and section captions share the Grammar surface's content edge." },
|
|
200
|
+
schema: [],
|
|
201
|
+
messages: { padding: "FE_LIST_PANEL_PADDING: horizontal padding stacks with the Grammar list inset. Remove this wrapper padding and use ListBox inset=\"panel\" so rows and section captions share the panel content edge." },
|
|
202
|
+
},
|
|
203
|
+
create(context) {
|
|
204
|
+
const reported = new Set()
|
|
205
|
+
const check = (opening) => {
|
|
206
|
+
const className = opening.attributes.find((entry) => ["className", "class"].some((name) => propStrings(context, entry, name).some((value) => classTokens(value.text).some(horizontalPadding))))
|
|
207
|
+
if (!className || reported.has(className)) return
|
|
208
|
+
reported.add(className)
|
|
209
|
+
context.report({ node: className, messageId: "padding" })
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
JSXOpeningElement(node) {
|
|
213
|
+
const name = grammarName(context, node.name)
|
|
214
|
+
if (name?.[0] === "ListBox" && (name.length === 1 || ["Section", "Header", "SectionHeader"].includes(name[1]))) {
|
|
215
|
+
check(node)
|
|
216
|
+
for (const ancestor of surfaceAncestors(context, node.parent)) {
|
|
217
|
+
check(ancestor)
|
|
218
|
+
if (isSurface(context, ancestor, node)) break
|
|
219
|
+
}
|
|
220
|
+
} else if (inSectionCaption(context, node)) check(node)
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The surface laws ship at error in every composing source tree. */
|
|
227
|
+
export const rules = { "nested-control-variant": nestedControlVariant, "no-list-panel-padding": noListPanelPadding }
|
|
228
|
+
|
|
229
|
+
/** No app can lower or omit a surface law. */
|
|
230
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
|