@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -5
  3. package/client-boundary.mjs +2 -5
  4. package/comments.mjs +38 -15
  5. package/docs/brand-values.md +2 -2
  6. package/docs/client-boundary.md +10 -10
  7. package/docs/env-owner.md +4 -4
  8. package/docs/formatting.md +2 -2
  9. package/docs/grammar-boundary.md +2 -2
  10. package/docs/hooks-folder.md +2 -2
  11. package/docs/hygiene.md +8 -8
  12. package/docs/lint-escape-hatch.md +2 -2
  13. package/docs/lists.md +6 -6
  14. package/docs/native-controls.md +6 -6
  15. package/docs/next-conventions.md +20 -20
  16. package/docs/size-and-state-budget.md +6 -6
  17. package/docs/size-growth.md +2 -2
  18. package/docs/status-colors.md +2 -2
  19. package/docs/translation.md +2 -2
  20. package/docs/transport.md +16 -16
  21. package/docs/type-safety.md +8 -8
  22. package/env-owner.mjs +2 -3
  23. package/file-layout.mjs +3 -43
  24. package/formatting.mjs +2 -2
  25. package/grammar-boundary.mjs +1 -2
  26. package/hooks-folder.mjs +6 -7
  27. package/index.mjs +3 -27
  28. package/lib/config.mjs +6 -22
  29. package/lib/scope.mjs +3 -12
  30. package/lib/why.mjs +0 -65
  31. package/lint-escape-hatch.mjs +3 -3
  32. package/lists.mjs +0 -4
  33. package/loading.mjs +2 -2
  34. package/native-controls.mjs +0 -4
  35. package/next-conventions.mjs +3 -7
  36. package/package.json +1 -1
  37. package/props-and-slots.mjs +4 -5
  38. package/runtime/knowledge/hfs/slots.yaml +145 -107
  39. package/runtime/scripts/lib/hfs-allows.mjs +15 -0
  40. package/runtime/scripts/lib/hfs-slots.mjs +13 -6
  41. package/runtime/scripts/lib/language.mjs +107 -0
  42. package/served-locale.mjs +2 -2
  43. package/shape-slot.mjs +2 -3
  44. package/size-and-state-budget.mjs +1 -4
  45. package/size-growth.mjs +1 -2
  46. package/status-colors.mjs +0 -2
  47. package/tokens.mjs +1 -1
  48. package/translation.mjs +1 -29
  49. package/transport.mjs +4 -8
  50. package/docs/e2e-shape.md +0 -161
  51. package/docs/spec-quality.md +0 -147
  52. package/e2e-shape.mjs +0 -345
  53. 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 !== 5) bad.push('ruleParams.be needs fileLines {soft, hardGrowth}, duplicateBlock {lines >= 2, tokens >= 1}, infraOwners, suffixes and bannedSuffixes');
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', 'title_vi', 'law', 'kinds', 'gates', 'failureCodes', 'enforcers'].includes(key)) bad.push(`${label} has unknown key ${key}`);
625
- const expectedId = `R${String(index + 1).padStart(2, '0')}`;
626
- if (!/^R\d{2}$/.test(String(r.id))) bad.push(`${at}.id must be R<two digits>`);
627
- else if (r.id !== expectedId) bad.push(`${label} is out of order: ${at} must be ${expectedId}`);
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
- for (const key of ['title_vi', 'law']) if (typeof r[key] !== 'string' || !r[key].trim()) bad.push(`${label}.${key} is missing`);
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, isSpecFile, stem } from "./lib/scope.mjs"
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) || isSpecFile(fileOf(context))) return {}
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, isSpecFile, kindOfFile, roleOfFile } from "./lib/scope.mjs"
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 && (isOwnIndex || spec)) return
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, isSpecFile, roleOfFile, slotOfFile } from "./lib/scope.mjs"
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 spec and an e2e file are
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, config, e2e and specs are out of scope.
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 { SECOND_LANGUAGE_LETTER, isContentFile } from "./comments.mjs"
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 the e2e double hid it by
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, isSpecFile, slotOfFile } from "./lib/scope.mjs"
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) || isSpecFile(filename)) return {}
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 (isSpecFile(context.filename || context.getFilename()) || isOutcomeModule(context)) return {}
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ư.