@starci/eslint-canon-fe 6.0.2 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +3 -5
- package/client-boundary.mjs +2 -5
- package/comments.mjs +38 -15
- package/docs/brand-values.md +2 -2
- package/docs/client-boundary.md +10 -10
- package/docs/env-owner.md +4 -4
- package/docs/formatting.md +2 -2
- package/docs/grammar-boundary.md +2 -2
- package/docs/hooks-folder.md +2 -2
- package/docs/hygiene.md +8 -8
- package/docs/lint-escape-hatch.md +2 -2
- package/docs/lists.md +6 -6
- package/docs/native-controls.md +6 -6
- package/docs/next-conventions.md +20 -20
- package/docs/size-and-state-budget.md +6 -6
- package/docs/size-growth.md +2 -2
- package/docs/status-colors.md +2 -2
- package/docs/translation.md +2 -2
- package/docs/transport.md +16 -16
- package/docs/type-safety.md +8 -8
- package/env-owner.mjs +2 -3
- package/file-layout.mjs +3 -43
- package/formatting.mjs +2 -2
- package/grammar-boundary.mjs +1 -2
- package/hooks-folder.mjs +6 -7
- package/index.mjs +3 -27
- package/lib/config.mjs +6 -22
- package/lib/scope.mjs +3 -12
- package/lib/why.mjs +0 -65
- package/lint-escape-hatch.mjs +3 -3
- package/lists.mjs +0 -4
- package/loading.mjs +2 -2
- package/native-controls.mjs +0 -4
- package/next-conventions.mjs +3 -7
- package/package.json +1 -1
- package/props-and-slots.mjs +4 -5
- package/runtime/knowledge/hfs/slots.yaml +145 -107
- package/runtime/scripts/lib/hfs-allows.mjs +15 -0
- package/runtime/scripts/lib/hfs-slots.mjs +13 -6
- package/runtime/scripts/lib/language.mjs +107 -0
- package/served-locale.mjs +2 -2
- package/shape-slot.mjs +2 -3
- package/size-and-state-budget.mjs +1 -4
- package/size-growth.mjs +1 -2
- package/status-colors.mjs +0 -2
- package/tokens.mjs +1 -1
- package/translation.mjs +1 -29
- package/transport.mjs +4 -8
- package/docs/e2e-shape.md +0 -161
- package/docs/spec-quality.md +0 -147
- package/e2e-shape.mjs +0 -345
- package/spec-quality.mjs +0 -318
|
@@ -26,6 +26,21 @@ export function relativeToRoot(file, root) {
|
|
|
26
26
|
return root && file.startsWith(`${root}/`) ? file.slice(root.length + 1) : path.posix.basename(file);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Whether the slot that owns `file` names it in `requires` or `allows` (and does not forbid it); null when no enabled slot
|
|
31
|
+
* owns it. Unlike allowsFile it judges a slot that lists only `requires` too, so a folder closed by its slot (an app's
|
|
32
|
+
* src/) is read from the slot and never from a check's own list.
|
|
33
|
+
*/
|
|
34
|
+
export function slotAdmitsFile(resolver, file) {
|
|
35
|
+
const classified = resolver.classifyPath(file);
|
|
36
|
+
if (classified.status === 'no-slot' || classified.status === 'ambiguous' || !classified.slot) return null;
|
|
37
|
+
const slot = resolver.slot(classified.slot);
|
|
38
|
+
const relative = relativeToRoot(classified.path, classified.root);
|
|
39
|
+
const hit = matchersOf([...(slot.requires ?? []), ...(slot.allows ?? [])], classified.bindings).find(matcher => matcher.expression.test(relative));
|
|
40
|
+
const forbidden = matchersOf(slot.forbids, classified.bindings).find(matcher => matcher.expression.test(relative) || matcher.expression.test(`${relative}/`));
|
|
41
|
+
return Boolean(hit) && !forbidden;
|
|
42
|
+
}
|
|
43
|
+
|
|
29
44
|
/**
|
|
30
45
|
* {slot, root, relative, allowed, entry?, forbiddenBy?} for a repository-relative file, or null when no enabled slot owns
|
|
31
46
|
* it or the slot names no `allows`. `allowed` is true when the relative path matches a `requires` or `allows` entry.
|
|
@@ -150,7 +150,8 @@ function manifestShapeProblems(m) {
|
|
|
150
150
|
const rp = m.ruleParams;
|
|
151
151
|
if (!isPlainObject(rp) || Object.keys(rp).some((k) => !PROFILES.includes(k)) || !PROFILES.every((p) => isPlainObject(rp[p]))) bad.push('ruleParams must be a map with be and fe');
|
|
152
152
|
else {
|
|
153
|
-
if (!fileLinesOk(rp.be.fileLines) || !blockOk(rp.be.duplicateBlock) || Object.keys(rp.be).length !==
|
|
153
|
+
if (!fileLinesOk(rp.be.fileLines) || !blockOk(rp.be.duplicateBlock) || Object.keys(rp.be).length !== 7) bad.push('ruleParams.be needs fileLines {soft, hardGrowth}, duplicateBlock {lines >= 2, tokens >= 1}, infraOwners, specDoubles, suffixes, bannedSuffixes and contractShape {helper}');
|
|
154
|
+
if (!isPlainObject(rp.be.contractShape) || Object.keys(rp.be.contractShape).length !== 1 || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(String(rp.be.contractShape.helper))) bad.push('ruleParams.be.contractShape must be {helper: <identifier>}');
|
|
154
155
|
const owners = rp.be.infraOwners;
|
|
155
156
|
const ownerId = /^(platform|integrations)\/[a-z][a-z0-9-]*$/;
|
|
156
157
|
if (!isPlainObject(owners) || !Object.keys(owners).length || !Object.entries(owners).every(([key, list]) => key && Array.isArray(list) && list.every((o) => ownerId.test(String(o))) && new Set(list).size === list.length)) bad.push('ruleParams.be.infraOwners must map a non-empty specifier to a list of unique platform/<capability> or integrations/<provider> owners ([] means nowhere)');
|
|
@@ -158,6 +159,12 @@ function manifestShapeProblems(m) {
|
|
|
158
159
|
if (!roleList(rp.be.suffixes)) bad.push('ruleParams.be.suffixes must be a non-empty list of unique kebab-case role suffixes');
|
|
159
160
|
if (!roleList(rp.be.bannedSuffixes)) bad.push('ruleParams.be.bannedSuffixes must be a non-empty list of unique kebab-case suffixes');
|
|
160
161
|
else if (roleList(rp.be.suffixes) && rp.be.suffixes.some((x) => rp.be.bannedSuffixes.includes(x))) bad.push('ruleParams.be.suffixes and bannedSuffixes must be disjoint');
|
|
162
|
+
const formNames = ['call', 'new', 'curried', 'object', 'primitive', 'array'];
|
|
163
|
+
const formsOk = (v) => Array.isArray(v) && v.length > 0 && v.every((x) => formNames.includes(x)) && new Set(v).size === v.length;
|
|
164
|
+
const doubleOk = (v) => isPlainObject(v) && /^[A-Za-z][A-Za-z0-9]*$/.test(String(v.double)) && formsOk(v.forms);
|
|
165
|
+
const regexOk = (v) => { try { return typeof v === 'string' && v.length > 0 && Boolean(new RegExp(v)); } catch { return false; } };
|
|
166
|
+
const sd = rp.be.specDoubles;
|
|
167
|
+
if (!isPlainObject(sd) || Object.keys(sd).sort().join() !== 'doubles,fallback,kit' || typeof sd.kit !== 'string' || !sd.kit || !Array.isArray(sd.doubles) || !sd.doubles.length || !sd.doubles.every((e) => doubleOk(e) && regexOk(e.token) && Object.keys(e).length === 3) || !doubleOk(sd.fallback) || Object.keys(sd.fallback).length !== 2) bad.push('ruleParams.be.specDoubles must be {kit, doubles: [{token: regex, double, forms}], fallback: {double, forms}} with forms drawn from call, new, curried, object, primitive, array');
|
|
161
168
|
if (!fileLinesOk(rp.fe.fileLines) || !blockOk(rp.fe.duplicateBlock) || Object.keys(rp.fe).length !== 2) bad.push('ruleParams.fe needs fileLines {soft, hardGrowth} and duplicateBlock {lines >= 2, tokens >= 1}');
|
|
162
169
|
}
|
|
163
170
|
if (!Array.isArray(m.slots) || !m.slots.length) { bad.push('slots must be a non-empty list'); return bad; }
|
|
@@ -621,12 +628,12 @@ function ruleCatalogProblems(d) {
|
|
|
621
628
|
const at = `rules[${index}]`;
|
|
622
629
|
if (!isPlainObject(r)) { bad.push(`${at} is not a map`); return; }
|
|
623
630
|
const label = typeof r.id === 'string' ? r.id : at;
|
|
624
|
-
for (const key of Object.keys(r)) if (!['id', 'code', '
|
|
625
|
-
|
|
626
|
-
if (!/^R\d{2}$/.test(String(r.id))) bad.push(`${at}.id must be R<two digits>`);
|
|
627
|
-
else if (r.id
|
|
631
|
+
for (const key of Object.keys(r)) if (!['id', 'code', 'law', 'kinds', 'gates', 'failureCodes', 'enforcers'].includes(key)) bad.push(`${label} has unknown key ${key}`);
|
|
632
|
+
// A retired rule leaves its id unused for good (never reused), so ids only have to increase.
|
|
633
|
+
if (!/^R\d{2,3}$/.test(String(r.id))) bad.push(`${at}.id must be R<two or three digits>`);
|
|
634
|
+
else if (index > 0 && typeof d.rules[index - 1]?.id === 'string' && Number(r.id.slice(1)) <= Number(d.rules[index - 1].id.slice(1))) bad.push(`${label} is out of order: ids must increase, and ${d.rules[index - 1].id} comes before it`);
|
|
628
635
|
if (!FINDING_CODE.test(String(r.code))) bad.push(`${label}.code must be an UPPER_SNAKE finding code`);
|
|
629
|
-
|
|
636
|
+
if (typeof r.law !== 'string' || !r.law.trim()) bad.push(`${label}.law is missing`);
|
|
630
637
|
if (typeof r.law === 'string' && r.law.includes('\n')) bad.push(`${label}.law must be one line`);
|
|
631
638
|
const enumList = (key, allowed) => {
|
|
632
639
|
if (!Array.isArray(r[key]) || !r[key].length) { bad.push(`${label}.${key} must be a non-empty list`); return []; }
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// language.mjs - the one home of "is this text English?" for source, comments, tests and docs (HFS_LANGUAGE_NOT_ENGLISH,
|
|
2
|
+
// BE_SOURCE_FORM, FE_SOURCE_FORM). Shared by the architecture machine (docs) and by both lint canons (each ships a byte
|
|
3
|
+
// copy in its runtime/ bundle, kept by packages/hfs/scripts/sync-runtime.mjs).
|
|
4
|
+
//
|
|
5
|
+
// Detection is structural on characters, never a word list: the letters Vietnamese adds to the Latin alphabet (a-breve, a-circumflex,
|
|
6
|
+
// d-stroke, e-circumflex, o-circumflex, o-horn, u-horn) and every vowel carrying a tone mark. Text is folded to NFC first, so a
|
|
7
|
+
// decomposed spelling (base letter + combining mark) is caught exactly like the precomposed one, and a loanword such as
|
|
8
|
+
// `naive`, `facade` or `Muller` (no tone mark, no Vietnamese letter) is never a hit.
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
/** One Vietnamese letter, precomposed (NFC): the Vietnamese-specific base letters, tone-marked vowels and the Latin Extended Additional block. */
|
|
12
|
+
export const SECOND_LANGUAGE_LETTER = /[À-ÃÈ-ÊÌÍÒ-ÕÙÚÝà-ãè-êìíò-õùúýĂăĐđĨĩŨũƠơƯưẠ-ỿ]/;
|
|
13
|
+
|
|
14
|
+
/** Whether `text` holds a Vietnamese letter, in NFC or NFD spelling. */
|
|
15
|
+
export const hasSecondLanguage = (text) => typeof text === 'string' && SECOND_LANGUAGE_LETTER.test(text.normalize('NFC'));
|
|
16
|
+
|
|
17
|
+
/** Every line of `text` with a Vietnamese letter: `[{ line, column }]`, both 1-based. */
|
|
18
|
+
export function secondLanguageHits(text) {
|
|
19
|
+
const hits = [];
|
|
20
|
+
String(text).split(/\r?\n/).forEach((raw, index) => {
|
|
21
|
+
const match = SECOND_LANGUAGE_LETTER.exec(raw.normalize('NFC'));
|
|
22
|
+
if (match) hits.push({ line: index + 1, column: match.index + 1 });
|
|
23
|
+
});
|
|
24
|
+
return hits;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The Vietnamese fields of one failure-code entry: the operator text the owner mandated for the failure-code catalog
|
|
29
|
+
* (scripts/checks/failure-codes.mjs types the entry with exactly these). causes_vi is the list companion of the three scalar fields.
|
|
30
|
+
*/
|
|
31
|
+
export const FAILURE_CODE_VIETNAMESE_FIELDS = Object.freeze(['title_vi', 'meaning_vi', 'nextStep_vi', 'causes_vi']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The fields of a document that carry deliberate Vietnamese text, keyed by file: a declared field-level exception of that file's
|
|
35
|
+
* typed content, never a path pattern over content. `fields` are block keys (the key's own value and its list items are exempt);
|
|
36
|
+
* `flowFields` are keys inside an inline flow map (`{ vi: ..., en: ... }`), where only that key's value is exempt. A Vietnamese
|
|
37
|
+
* value under any other key of the same file is still a finding.
|
|
38
|
+
* - modules/kernel/failure-codes.yaml: the operator text the owner mandated for the failure-code catalog.
|
|
39
|
+
* - modules/ops/_labels.yaml: the op-label catalogue, one `{ vi, en }` pair per op (the `vi` field is the localized label).
|
|
40
|
+
* - modules/goal/archetypes.yaml: the Vietnamese phrase lexicons matched against owner input (the signal phrase sets, and the
|
|
41
|
+
* phrase lists of the archetype recognisers).
|
|
42
|
+
*/
|
|
43
|
+
export const DECLARED_VIETNAMESE_FIELDS = Object.freeze({
|
|
44
|
+
'modules/kernel/failure-codes.yaml': Object.freeze({ fields: FAILURE_CODE_VIETNAMESE_FIELDS }),
|
|
45
|
+
'modules/ops/_labels.yaml': Object.freeze({ flowFields: Object.freeze(['vi']) }),
|
|
46
|
+
'modules/goal/archetypes.yaml': Object.freeze({
|
|
47
|
+
fields: Object.freeze([
|
|
48
|
+
'buildIntent', 'canonIntent', 'e2eIntent', 'uatIntent', 'proofNegation', 'integrationIntent', 'integrationNegation', 'brandIntent',
|
|
49
|
+
'phrases', 'requires', 'excludes', 'backend', 'frontend', 'package',
|
|
50
|
+
]),
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The slots whose files may carry another language, and why: a catalog is product copy in two languages, and an i18n
|
|
56
|
+
* fixture reproduces a real localized string a parser or formatter must accept. Placement is the whole marker - there is no
|
|
57
|
+
* comment pragma and no path pattern. The slots are declared in knowledge/hfs/slots.yaml.
|
|
58
|
+
*/
|
|
59
|
+
export const LOCALIZED_TEXT_SLOTS = Object.freeze([
|
|
60
|
+
'be.domain.messages', 'be.feature.messages', 'be.tests.fixtures.i18n',
|
|
61
|
+
'fe.modules.i18n', 'fe.package.i18n',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** Whether a document (Markdown or YAML) of `slot` is exempt: only catalogue and fixture DATA (YAML) is; Markdown is prose and never is. */
|
|
65
|
+
export const isLocalizedDataFile = (rel, slot) => LOCALIZED_TEXT_SLOTS.includes(slot) && /\.ya?ml$/.test(rel);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The key each line of a YAML text belongs to: a `key:` line owns itself, and every deeper-indented line after it (a list item,
|
|
69
|
+
* a folded block, a wrapped scalar) belongs to the same key. A line at or above the key's indent that is not a key belongs to none.
|
|
70
|
+
*/
|
|
71
|
+
export function yamlKeyOfEachLine(text) {
|
|
72
|
+
let key = null;
|
|
73
|
+
let keyIndent = -1;
|
|
74
|
+
return String(text).split(/\r?\n/).map((raw) => {
|
|
75
|
+
if (raw.trim() === '') return key;
|
|
76
|
+
const indent = raw.length - raw.trimStart().length;
|
|
77
|
+
const own = /^(\s*(?:-\s+)?)([A-Za-z_][\w-]*)\s*:(?:\s|$)/.exec(raw);
|
|
78
|
+
if (own) { key = own[2]; keyIndent = own[1].length; return key; }
|
|
79
|
+
// a list item may sit at the key's own indent (`key:` then `- item` at the same column)
|
|
80
|
+
if (indent > keyIndent || (indent === keyIndent && /^\s*-\s/.test(raw))) return key;
|
|
81
|
+
key = null;
|
|
82
|
+
keyIndent = -1;
|
|
83
|
+
return null;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The Vietnamese hits of a document, minus the declared field-level exceptions of `rel` (a repository-relative POSIX path). */
|
|
88
|
+
export function documentLanguageHits(rel, text) {
|
|
89
|
+
const declared = DECLARED_VIETNAMESE_FIELDS[rel];
|
|
90
|
+
const hits = secondLanguageHits(text);
|
|
91
|
+
if (!declared) return hits;
|
|
92
|
+
const keys = yamlKeyOfEachLine(text);
|
|
93
|
+
const lines = String(text).split(/\r?\n/);
|
|
94
|
+
return hits.filter((hit) => {
|
|
95
|
+
if (declared.fields?.includes(keys[hit.line - 1])) return false;
|
|
96
|
+
if (!declared.flowFields) return true;
|
|
97
|
+
// an inline flow map: drop the declared fields' values and judge what is left of the line
|
|
98
|
+
const rest = declared.flowFields.reduce((line, field) => line.replace(new RegExp(`\\b${field}\\s*:\\s*[^,}]*`, 'g'), ''), lines[hit.line - 1]);
|
|
99
|
+
return hasSecondLanguage(rest);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The file extensions of a prose document (Markdown and YAML). */
|
|
104
|
+
export const DOCUMENT_EXTENSIONS = Object.freeze(['.md', '.yaml', '.yml']);
|
|
105
|
+
|
|
106
|
+
/** Whether `rel` is a document a language check reads. */
|
|
107
|
+
export const isDocument = (rel) => DOCUMENT_EXTENSIONS.includes(path.posix.extname(rel));
|
package/served-locale.mjs
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
35
|
-
import { fileOf, inSlot,
|
|
35
|
+
import { fileOf, inSlot, stem } from "./lib/scope.mjs"
|
|
36
36
|
|
|
37
37
|
/** The slots that hold transport: the app's `modules/api` and the shared api package. Neither slot names its links, see the note on `linkFileName`. */
|
|
38
38
|
const TRANSPORT_SLOTS = ["fe.modules.api", "fe.package.api"]
|
|
@@ -124,7 +124,7 @@ export const rules = {
|
|
|
124
124
|
// A single link's implementation is not a chain - `links/http.ts` constructs the terminal link
|
|
125
125
|
// because defining it IS its job, and there is no correct way to attach a locale there. A
|
|
126
126
|
// spec asserts about a chain rather than being one.
|
|
127
|
-
if (isLinkImplementationFile(context)
|
|
127
|
+
if (isLinkImplementationFile(context)) return {}
|
|
128
128
|
let terminalNode = null
|
|
129
129
|
let attachesLocale = false
|
|
130
130
|
const note = (node) => {
|
package/shape-slot.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
23
|
-
import { classifyImport, fileOf, inSlot,
|
|
23
|
+
import { classifyImport, fileOf, inSlot, kindOfFile, roleOfFile } from "./lib/scope.mjs"
|
|
24
24
|
|
|
25
25
|
/** The layers and feature kinds that split into a connected `index.tsx` and a pure `component.tsx`; a shared package's layers never split. */
|
|
26
26
|
const SPLIT_KINDS = new Set(["blocks", "pages", "layouts", "overlays"])
|
|
@@ -206,7 +206,6 @@ export const baseImportPair = {
|
|
|
206
206
|
},
|
|
207
207
|
create(context) {
|
|
208
208
|
const isOwnIndex = roleOfFile(context) === "entry"
|
|
209
|
-
const spec = isSpecFile(fileOf(context))
|
|
210
209
|
const inTier = inSplitTier(context)
|
|
211
210
|
// An import reaches a pure half when the file it resolves to has the `drawing` role of its slot.
|
|
212
211
|
const reachesPure = (source) => classifyImport(context, source)?.role === "drawing"
|
|
@@ -214,7 +213,7 @@ export const baseImportPair = {
|
|
|
214
213
|
const check = (node, source) => {
|
|
215
214
|
if (typeof source !== "string" || !reachesPure(source)) return
|
|
216
215
|
const isSibling = /^\.\/[^/]+$/.test(source)
|
|
217
|
-
if (isSibling &&
|
|
216
|
+
if (isSibling && isOwnIndex) return
|
|
218
217
|
if (isSibling && !inTier) return
|
|
219
218
|
context.report({ node, messageId: "reach", data: { source } })
|
|
220
219
|
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { functionOf, isFn } from "./lib/bindings.mjs"
|
|
22
22
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
23
23
|
import { isPromiseValued } from "./lib/types.mjs"
|
|
24
|
-
import { fileOf,
|
|
24
|
+
import { fileOf, roleOfFile, slotOfFile } from "./lib/scope.mjs"
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* The budget the slot of the linted file states (`budget` in knowledge/hfs/slots.yaml), or an empty one when no slot owns the file
|
|
@@ -86,7 +86,6 @@ export const componentLineBudget = {
|
|
|
86
86
|
},
|
|
87
87
|
create(context) {
|
|
88
88
|
// A spec is not held to a budget; a file has one when its role or its slot states it.
|
|
89
|
-
if (isSpecFile(fileOf(context))) return {}
|
|
90
89
|
const max = lineBudgetOf(context)
|
|
91
90
|
if (max === null) return {}
|
|
92
91
|
const source = context.sourceCode || context.getSourceCode()
|
|
@@ -116,7 +115,6 @@ export const unitHookBudget = {
|
|
|
116
115
|
},
|
|
117
116
|
},
|
|
118
117
|
create(context) {
|
|
119
|
-
if (isSpecFile(fileOf(context))) return {}
|
|
120
118
|
// A slot that states no `useState` / `dataHooks` budget puts no bound on that count: the rule refuses to invent one.
|
|
121
119
|
const { useState: maxState, dataHooks: maxData } = budgetOf(context)
|
|
122
120
|
if (typeof maxState !== "number" && typeof maxData !== "number") return {}
|
|
@@ -221,7 +219,6 @@ export const noHandRolledPolling = {
|
|
|
221
219
|
},
|
|
222
220
|
},
|
|
223
221
|
create(context) {
|
|
224
|
-
if (isSpecFile(fileOf(context))) return {}
|
|
225
222
|
const source = context.sourceCode || context.getSourceCode()
|
|
226
223
|
return {
|
|
227
224
|
CallExpression(node) {
|
package/size-growth.mjs
CHANGED
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
* (`git show HEAD:<file>`). A file may stay as large as it is; it may not grow, and it may not be born large. The rule
|
|
9
9
|
* takes no option: the budget is the manifest's, the baseline is git's.
|
|
10
10
|
*
|
|
11
|
-
* There is no baseline file and no allowlist: the record is the repository's own history. A
|
|
12
|
-
* source files like any other; a declaration file (`*.d.ts`) carries no behaviour and is not governed. Component,
|
|
11
|
+
* There is no baseline file and no allowlist: the record is the repository's own history. A declaration file (`*.d.ts`) carries no behaviour and is not governed. Component,
|
|
13
12
|
* hook and state budgets are stricter still where they apply (`size-and-state-budget`); this ratchet is the one that
|
|
14
13
|
* needs the previous revision.
|
|
15
14
|
*/
|
package/status-colors.mjs
CHANGED
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { attributeValue } from "./lib/ast.mjs"
|
|
23
|
-
import { isSpecFile } from "./lib/scope.mjs"
|
|
24
23
|
import { STATUS_TONES } from "./lib/status-tones.generated.mjs"
|
|
25
24
|
|
|
26
25
|
/** The utility families that paint text, an icon or a text decoration. */
|
|
@@ -106,7 +105,6 @@ export const statusTextUsesSoftForeground = {
|
|
|
106
105
|
},
|
|
107
106
|
},
|
|
108
107
|
create(context) {
|
|
109
|
-
if (isSpecFile(context.filename || context.getFilename())) return {}
|
|
110
108
|
// A `classes` entry inside a constant is reached twice (as part of the constant, and as the entry): one finding per string.
|
|
111
109
|
const seen = new Set()
|
|
112
110
|
const check = (root) => {
|
package/tokens.mjs
CHANGED
|
@@ -62,7 +62,7 @@ const staticText = (value) => {
|
|
|
62
62
|
|
|
63
63
|
/** Walk every place a class string can be written: markup, a constant, or an entry's array. */
|
|
64
64
|
const classTextVisitors = (context, report) => {
|
|
65
|
-
// Product source is a file of a product tier (a slot says so); tooling
|
|
65
|
+
// Product source is a file of a product tier (a slot says so); tooling and config are out of scope.
|
|
66
66
|
if (!isProductSource(context)) return {}
|
|
67
67
|
return {
|
|
68
68
|
JSXAttribute(node) {
|
package/translation.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* and spares tokens is worth more than a clever one nobody trusts.
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
-
import {
|
|
31
|
+
import { isContentFile } from "./comments.mjs"
|
|
32
32
|
import { isComponentFile, kindOfFile } from "./lib/scope.mjs"
|
|
33
33
|
|
|
34
34
|
/** True when a file sits in a component layer that must not resolve copy: every layer but `blocks` receives every word it renders, so it can know no domain and no sentence. */
|
|
@@ -203,24 +203,12 @@ export const noHardcodedCopy = {
|
|
|
203
203
|
"`{{attr}}=\"{{text}}\"` is copy, hardcoded. One reader in another language sees it verbatim - and `{{attr}}` is where copy hides, because it does not read as a sentence when scanning the file. Use `t(\"key\")`.",
|
|
204
204
|
property:
|
|
205
205
|
"`{{key}}: \"{{text}}\"` is copy in an object, so whatever displays this object (a page title, an empty state, a toast) shows it in one language. Use `t(\"key\")`, or build the object where `t` is in scope.",
|
|
206
|
-
second:
|
|
207
|
-
"A second-language string in source. Text in another language is content, and content lives in `messages/<locale>.json` behind `t()`; there is no pragma that lets it stay here.",
|
|
208
|
-
comment:
|
|
209
|
-
"A second-language comment. Source is English so that every reader can read all of it; the product's other language belongs in the catalogue.",
|
|
210
206
|
},
|
|
211
207
|
},
|
|
212
208
|
create(context) {
|
|
213
209
|
if (isContentFile(context)) return {}
|
|
214
210
|
const source = context.sourceCode || context.getSourceCode()
|
|
215
|
-
/** Value nodes already reported through their owner (attribute, property), so a string reports once. */
|
|
216
|
-
const claimed = new WeakSet()
|
|
217
|
-
|
|
218
211
|
return {
|
|
219
|
-
Program() {
|
|
220
|
-
for (const comment of source.getAllComments()) {
|
|
221
|
-
if (SECOND_LANGUAGE_LETTER.test(comment.value)) context.report({ node: comment, messageId: "comment" })
|
|
222
|
-
}
|
|
223
|
-
},
|
|
224
212
|
JSXText(node) {
|
|
225
213
|
const text = String(node.value || "").trim()
|
|
226
214
|
if (hasLetter(text)) context.report({ node, messageId: "text", data: { text } })
|
|
@@ -230,12 +218,10 @@ export const noHardcodedCopy = {
|
|
|
230
218
|
const parent = node.parent
|
|
231
219
|
if (!parent || (parent.type !== "JSXElement" && parent.type !== "JSXFragment")) return
|
|
232
220
|
if (templateHasWord(node.expression)) {
|
|
233
|
-
claimed.add(node.expression)
|
|
234
221
|
return context.report({ node, messageId: "text", data: { text: source.getText(node.expression) } })
|
|
235
222
|
}
|
|
236
223
|
const text = staticString(node.expression)
|
|
237
224
|
if (text === null || !hasLetter(text.trim())) return
|
|
238
|
-
claimed.add(node.expression)
|
|
239
225
|
context.report({ node, messageId: "text", data: { text: text.trim() } })
|
|
240
226
|
},
|
|
241
227
|
JSXAttribute(node) {
|
|
@@ -248,7 +234,6 @@ export const noHardcodedCopy = {
|
|
|
248
234
|
const text = template ?? attributeText(node)
|
|
249
235
|
if (text === null) return
|
|
250
236
|
if (strict ? !hasLetter(text.replaceAll("#", " ")) : !looksLikeProse(text)) return
|
|
251
|
-
claimed.add(value)
|
|
252
237
|
context.report({ node, messageId: "attribute", data: { attr, text: template === null ? text : source.getText(value) } })
|
|
253
238
|
},
|
|
254
239
|
Property(node) {
|
|
@@ -259,21 +244,8 @@ export const noHardcodedCopy = {
|
|
|
259
244
|
if (text === null) return
|
|
260
245
|
// a copy key needs prose; any other key needs a whole sentence: { ready: "Your course is ready" } in a hook is copy
|
|
261
246
|
if (!(COPY_KEYS.has(key) ? looksLikeProse(text) : looksLikeSentence(text))) return
|
|
262
|
-
claimed.add(node.value)
|
|
263
247
|
context.report({ node, messageId: "property", data: { key, text: template === null ? text : source.getText(node.value) } })
|
|
264
248
|
},
|
|
265
|
-
Literal(node) {
|
|
266
|
-
if (claimed.has(node) || typeof node.value !== "string") return
|
|
267
|
-
if (SECOND_LANGUAGE_LETTER.test(node.value)) context.report({ node, messageId: "second" })
|
|
268
|
-
},
|
|
269
|
-
TemplateElement(node) {
|
|
270
|
-
if (SECOND_LANGUAGE_LETTER.test((node.value && node.value.cooked) || "")) {
|
|
271
|
-
context.report({ node, messageId: "second" })
|
|
272
|
-
}
|
|
273
|
-
},
|
|
274
|
-
Identifier(node) {
|
|
275
|
-
if (SECOND_LANGUAGE_LETTER.test(node.name)) context.report({ node, messageId: "second" })
|
|
276
|
-
},
|
|
277
249
|
}
|
|
278
250
|
},
|
|
279
251
|
}
|
package/transport.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* defects the three halves prevent were measured on real apps: seven transports and six result
|
|
9
9
|
* shapes in one repository, and in another a client that folded every non-2xx into "could not
|
|
10
10
|
* read", so the state a real backend produces for a signed-out reader - 401, which must become
|
|
11
|
-
* `refused` and send the reader to sign in - was unreachable, while
|
|
11
|
+
* `refused` and send the reader to sign in - was unreachable, while a test double hid it by
|
|
12
12
|
* answering 200.
|
|
13
13
|
*
|
|
14
14
|
* WHAT THESE RULES CANNOT SEE. They read one file at a time, so they cannot tell that a client's
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import ts from "typescript"
|
|
21
21
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
22
|
-
import { globalReferences, isApiClient, isOutcomeModule,
|
|
22
|
+
import { globalReferences, isApiClient, isOutcomeModule, slotOfFile } from "./lib/scope.mjs"
|
|
23
23
|
import { typed } from "./lib/types.mjs"
|
|
24
24
|
|
|
25
25
|
/** Packages that send an HTTP request: a second transport is a second owner. Matched on the module specifier, never on a file name. */
|
|
@@ -118,7 +118,6 @@ export const fetchOnlyInApiClient = {
|
|
|
118
118
|
},
|
|
119
119
|
create(context) {
|
|
120
120
|
const filename = context.filename || context.getFilename()
|
|
121
|
-
if (isSpecFile(filename)) return {}
|
|
122
121
|
const client = isApiClient(context)
|
|
123
122
|
const library = (node, source) => {
|
|
124
123
|
if (typeof source === "string" && isTransportLibrary(source)) context.report({ node, messageId: "library", data: { name: source } })
|
|
@@ -224,7 +223,7 @@ export const noSharedTransportState = {
|
|
|
224
223
|
},
|
|
225
224
|
create(context) {
|
|
226
225
|
const filename = context.filename || context.getFilename()
|
|
227
|
-
if (!isTransportFile(context)
|
|
226
|
+
if (!isTransportFile(context)) return {}
|
|
228
227
|
const check = (node) => {
|
|
229
228
|
const declaration = node.type === "VariableDeclaration" ? node : node.declaration
|
|
230
229
|
if (!declaration || declaration.type !== "VariableDeclaration" || declaration.kind === "const") return
|
|
@@ -492,7 +491,6 @@ export const noHttpStatusCollapse = {
|
|
|
492
491
|
},
|
|
493
492
|
},
|
|
494
493
|
create(context) {
|
|
495
|
-
if (isSpecFile(context.filename || context.getFilename())) return {}
|
|
496
494
|
const source = context.sourceCode || context.getSourceCode()
|
|
497
495
|
return {
|
|
498
496
|
IfStatement(node) {
|
|
@@ -578,7 +576,6 @@ export const noHandTypedWire = {
|
|
|
578
576
|
},
|
|
579
577
|
create(context) {
|
|
580
578
|
const filename = context.filename || context.getFilename()
|
|
581
|
-
if (isSpecFile(filename)) return {}
|
|
582
579
|
const source = context.sourceCode ?? context.getSourceCode()
|
|
583
580
|
/** Local names of the type imports that resolve into a `__generated__/` directory. */
|
|
584
581
|
const generated = new Set()
|
|
@@ -650,7 +647,6 @@ export const outcomeKindsExhaustive = {
|
|
|
650
647
|
},
|
|
651
648
|
},
|
|
652
649
|
create(context) {
|
|
653
|
-
if (isSpecFile(context.filename || context.getFilename())) return {}
|
|
654
650
|
return {
|
|
655
651
|
SwitchStatement(node) {
|
|
656
652
|
const subject = node.discriminant
|
|
@@ -704,7 +700,7 @@ export const oneOutcomeUnion = {
|
|
|
704
700
|
},
|
|
705
701
|
},
|
|
706
702
|
create(context) {
|
|
707
|
-
if (
|
|
703
|
+
if (isOutcomeModule(context)) return {}
|
|
708
704
|
const { checker, toTs } = typed(context)
|
|
709
705
|
return {
|
|
710
706
|
TSTypeAliasDeclaration(node) {
|
package/docs/e2e-shape.md
DELETED
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
# e2e shape
|
|
2
|
-
|
|
3
|
-
Law module: `e2e-shape.mjs`. Catalogue: R66 FE_E2E_SHAPE.
|
|
4
|
-
|
|
5
|
-
These rules govern `e2e/**` and `playwright.config.*`, not `src/`; `starciFeConfig` attaches them through its second block. Specs are `e2e/<area>/<name>.e2e-spec.ts`; the Playwright projects cover 1440x900, 768x1024 and 390x844; no absolute path, no docker, no write outside the repository, no `test.skip`, and typed helpers.
|
|
6
|
-
|
|
7
|
-
Every rule below is an error in `starciFeConfig`; none can be switched off or suppressed inline.
|
|
8
|
-
|
|
9
|
-
## `starci-fe/e2e-spec-location`
|
|
10
|
-
|
|
11
|
-
An e2e spec is `e2e/<area>/<name>.e2e-spec.ts`; helpers live under `support/` or `fixtures/`.
|
|
12
|
-
|
|
13
|
-
**Invalid** (`e2e/play.spec.ts`)
|
|
14
|
-
|
|
15
|
-
```ts
|
|
16
|
-
// file: e2e/play.spec.ts
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
**Valid** (`e2e/course/play.e2e-spec.ts`)
|
|
20
|
-
|
|
21
|
-
```ts
|
|
22
|
-
// file: e2e/course/play.e2e-spec.ts
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
**Finding code:** `FE_E2E_SHAPE`
|
|
26
|
-
|
|
27
|
-
**Vì sao (why):** Spec e2e `<file>` không nằm ở `e2e/<area>/<name>.e2e-spec.ts`.
|
|
28
|
-
|
|
29
|
-
**Cách sửa:** Đưa spec vào `e2e/<area>/` với hậu tố `.e2e-spec.ts`; helper vào `e2e/support/` hoặc `e2e/fixtures/`.
|
|
30
|
-
|
|
31
|
-
## `starci-fe/e2e-no-absolute-path`
|
|
32
|
-
|
|
33
|
-
No absolute filesystem path in e2e source.
|
|
34
|
-
|
|
35
|
-
**Invalid** (`e2e/support/session.ts`)
|
|
36
|
-
|
|
37
|
-
```ts
|
|
38
|
-
const p = "D:/repos/nivo-backend/out"
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
**Valid** (`e2e/support/session.ts`)
|
|
42
|
-
|
|
43
|
-
```ts
|
|
44
|
-
const p = path.join(__dirname, "fixtures", "a.png")
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
**Finding code:** `FE_E2E_SHAPE`
|
|
48
|
-
|
|
49
|
-
**Vì sao (why):** Spec e2e `<file>` dùng đường dẫn tuyệt đối.
|
|
50
|
-
|
|
51
|
-
**Cách sửa:** Dựng đường dẫn từ cấu hình hoặc thư mục của chính spec.
|
|
52
|
-
|
|
53
|
-
## `starci-fe/e2e-no-docker`
|
|
54
|
-
|
|
55
|
-
An e2e suite does not start or require Docker.
|
|
56
|
-
|
|
57
|
-
**Invalid** (`e2e/support/stack.ts`)
|
|
58
|
-
|
|
59
|
-
```ts
|
|
60
|
-
execSync("docker compose up -d")
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
**Valid** (`e2e/course/play.e2e-spec.ts`)
|
|
64
|
-
|
|
65
|
-
```ts
|
|
66
|
-
await page.goto(baseUrl)
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
**Finding code:** `FE_E2E_SHAPE`
|
|
70
|
-
|
|
71
|
-
**Vì sao (why):** Spec e2e `<file>` phụ thuộc vào docker.
|
|
72
|
-
|
|
73
|
-
**Cách sửa:** Trỏ suite tới một URL từ cấu hình; môi trường tự cung cấp stack.
|
|
74
|
-
|
|
75
|
-
## `starci-fe/e2e-no-cross-repo-write`
|
|
76
|
-
|
|
77
|
-
An e2e file write never targets an absolute path or climbs out of the repository.
|
|
78
|
-
|
|
79
|
-
**Invalid** (`e2e/support/seed.ts`)
|
|
80
|
-
|
|
81
|
-
```ts
|
|
82
|
-
await fs.writeFile("../backend/seed.json", data)
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
**Valid** (`e2e/support/seed.ts`)
|
|
86
|
-
|
|
87
|
-
```ts
|
|
88
|
-
await fs.writeFile(path.join(outDir, "report.json"), data)
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
**Finding code:** `FE_E2E_SHAPE`
|
|
92
|
-
|
|
93
|
-
**Vì sao (why):** Spec e2e `<file>` ghi ra ngoài repo của app (đường dẫn tuyệt đối hoặc `..`).
|
|
94
|
-
|
|
95
|
-
**Cách sửa:** Chỉ ghi vào thư mục kết quả của chính repo này.
|
|
96
|
-
|
|
97
|
-
## `starci-fe/e2e-no-skip`
|
|
98
|
-
|
|
99
|
-
No `test.skip`, `test.fixme` or `describe.skip`: a missing environment fails the run.
|
|
100
|
-
|
|
101
|
-
**Invalid** (`e2e/course/play.e2e-spec.ts`)
|
|
102
|
-
|
|
103
|
-
```ts
|
|
104
|
-
test.skip(!process.env.BASE_URL, "needs env")
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
**Valid** (`e2e/course/play.e2e-spec.ts`)
|
|
108
|
-
|
|
109
|
-
```ts
|
|
110
|
-
test("plays", async ({ page }) => {})
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
**Finding code:** `FE_E2E_SHAPE`
|
|
114
|
-
|
|
115
|
-
**Vì sao (why):** Spec e2e `<file>` bỏ qua test (`test.skip`/`fixme`) — thiếu môi trường phải làm suite đỏ, không được xanh.
|
|
116
|
-
|
|
117
|
-
**Cách sửa:** Sửa môi trường hoặc xóa test; không bỏ qua có điều kiện.
|
|
118
|
-
|
|
119
|
-
## `starci-fe/e2e-typed-helpers`
|
|
120
|
-
|
|
121
|
-
e2e helper parameters are typed and nothing is `any`.
|
|
122
|
-
|
|
123
|
-
**Invalid** (`e2e/support/session.ts`)
|
|
124
|
-
|
|
125
|
-
```ts
|
|
126
|
-
export const login = async (page, role) => page
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
**Valid** (`e2e/support/session.ts`)
|
|
130
|
-
|
|
131
|
-
```ts
|
|
132
|
-
export const login = async (page: Page, role: Role) => page
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
**Finding code:** `FE_E2E_SHAPE`
|
|
136
|
-
|
|
137
|
-
**Vì sao (why):** Helper e2e ở `<file>` có tham số không kiểu hoặc dùng `any`.
|
|
138
|
-
|
|
139
|
-
**Cách sửa:** Khai kiểu cho mọi tham số của helper; thay `any` bằng kiểu thật hoặc `unknown` kèm kiểm tra.
|
|
140
|
-
|
|
141
|
-
## `starci-fe/playwright-viewports`
|
|
142
|
-
|
|
143
|
-
playwright.config declares the viewports 1440x900, 768x1024, 390x844 and no others.
|
|
144
|
-
|
|
145
|
-
**Invalid** (`playwright.config.ts`)
|
|
146
|
-
|
|
147
|
-
```ts
|
|
148
|
-
projects: [{ use: { viewport: { width: 1440, height: 900 } } }]
|
|
149
|
-
```
|
|
150
|
-
|
|
151
|
-
**Valid** (`playwright.config.ts`)
|
|
152
|
-
|
|
153
|
-
```ts
|
|
154
|
-
projects: three, each with one of 1440x900, 768x1024, 390x844
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
**Finding code:** `FE_E2E_SHAPE`
|
|
158
|
-
|
|
159
|
-
**Vì sao (why):** `playwright.config` không khai đúng ba viewport 1440x900, 768x1024, 390x844.
|
|
160
|
-
|
|
161
|
-
**Cách sửa:** Mỗi project khai một viewport; đủ ba, không thêm cỡ thứ tư.
|