@starci/eslint-canon-fe 9.6.2 → 9.6.5

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 9.6.5 - 2026-10-31
4
+
5
+ - Changed: the bundled runtime copies follow @starci/hfs 4.13.2 (R344 on the `be.integrations` slot) and the canon pins name hfs 4.13.2, eslint-canon-be 3.10.0, eslint-canon-fe 9.6.5, eslint-grammar-core and eslint-grammar-offset-pop 0.16.5; no rule changed.
6
+
7
+ ## 9.6.4 - 2026-10-31
8
+
9
+ - Changed: the bundled canon pins name @starci/hfs 4.13.1 and the lint cohort (eslint-canon-be 3.9.4, eslint-canon-fe 9.6.4, eslint-grammar-core and eslint-grammar-offset-pop 0.16.4); no rule changed.
10
+
11
+ ## 9.6.3 - 2026-10-31
12
+
13
+ - Changed: the bundled canon pins name the 0.16.3 cohort (@starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.16.3, eslint-canon-fe 9.6.3, eslint-canon-be 3.9.3, stylelint-canon 2.3.0, jest-preset 2.6.0, hfs 4.13.0); ChatWorkspace and PaneLayout are removed with no alias; PaneGrid and Pane are the one generic pane grid: grammar names are generic. The surface-rules spec uses PaneGrid.
14
+ - Added: lib/freestyle (`exemptFreestyle`, `freestyleContext`): no-arbitrary-value, no-app-surface-paint, no-raw-brand-value, no-list-panel-padding, no-inline-class-name and no-native-img do not report the raw elements inside a `<Freestyle>` subtree of the same file; a Grammar component inside it is judged as before (owner ruling grammar-freestyle-region). Specs: src/freestyle.spec.ts.
15
+ - Added: the lint result cache of `starci app check` (ruling app-check-cache): `crossFileInputs`, the files and folders each rule reads beyond the linted file (an fs-audit spec proves every rule declares what it reads), and the `projectGraph` export the cache keys a file's lint record on.
16
+
3
17
  ## 9.6.2 - 2026-10-31
4
18
 
5
19
  - Changed: the bundled canon pins name @starci/grammar 0.16.2 (the horizontal Stepper connector line; SurfaceListCard takes its depth from the surface context).
@@ -19,10 +19,10 @@
19
19
  */
20
20
  import { type Recommended } from "./lib/rule.js";
21
21
  /** No raw colour, hex or pixel value outside `brand.css`. */
22
- export declare const noRawBrandValue: import("./lib/rule.js").Rule<"color" | "length" | "named", []>;
22
+ export declare const noRawBrandValue: import("./lib/rule.js").Rule<"length" | "color" | "named", []>;
23
23
  /** The rules this law contributes to the plugin. */
24
24
  export declare const rules: {
25
- "no-raw-brand-value": import("./lib/rule.js").Rule<"color" | "length" | "named", []>;
25
+ "no-raw-brand-value": import("./lib/rule.js").Rule<"length" | "color" | "named", []>;
26
26
  };
27
27
  /** Every rule is an error. */
28
28
  export declare const recommended: Recommended;
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { isContentFile } from "./comments.js";
21
21
  import { defineRule } from "./lib/rule.js";
22
+ import { exemptFreestyle } from "./lib/freestyle.js";
22
23
  /** A hex colour: 3, 4, 6 or 8 digits, not a longer word and not part of an identifier. */
23
24
  const HEX_COLOR = /(?<![\w&])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})(?![\w-])/;
24
25
  /** A functional colour notation. */
@@ -84,7 +85,7 @@ const staticString = (node) => {
84
85
  return null;
85
86
  };
86
87
  /** No raw colour, hex or pixel value outside `brand.css`. */
87
- export const noRawBrandValue = defineRule({
88
+ export const noRawBrandValue = exemptFreestyle(defineRule({
88
89
  meta: {
89
90
  type: "problem",
90
91
  docs: { description: "No hex, colour function, named paint colour or px length in TypeScript; tokens only." },
@@ -163,7 +164,7 @@ export const noRawBrandValue = defineRule({
163
164
  },
164
165
  };
165
166
  },
166
- });
167
+ }));
167
168
  /** The rules this law contributes to the plugin. */
168
169
  export const rules = {
169
170
  "no-raw-brand-value": noRawBrandValue,
@@ -3,6 +3,7 @@
3
3
  * classNames files own the utility-token composition.
4
4
  */
5
5
  import { defineRule } from "./lib/rule.js";
6
+ import { exemptFreestyle } from "./lib/freestyle.js";
6
7
  import { isComponentFile, roleOfFile } from "./lib/scope.js";
7
8
  /** The owner a cn call may sit in, and the exports that publish one. */
8
9
  const FUNCTION_OWNERS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
@@ -90,7 +91,7 @@ const usesImportedClassName = (node, imported) => {
90
91
  return false;
91
92
  };
92
93
  /** Report className values written directly in component JSX. */
93
- export const noInlineClassName = defineRule({
94
+ export const noInlineClassName = exemptFreestyle(defineRule({
94
95
  meta: {
95
96
  type: "problem",
96
97
  docs: { description: "Component JSX must import a named className declaration." },
@@ -111,7 +112,7 @@ export const noInlineClassName = defineRule({
111
112
  },
112
113
  };
113
114
  },
114
- });
115
+ }));
115
116
  /** Require every classNames declaration to use the colocated classNames.ts convention. */
116
117
  export const classNamesInColocatedFile = defineRule({
117
118
  meta: {
package/dist/index.d.ts CHANGED
@@ -103,3 +103,7 @@ export declare const starciPlainSourceConfig: ({ root }?: {
103
103
  export declare const starciTypedSourceConfig: ({ root }?: {
104
104
  readonly root?: unknown;
105
105
  }) => Linter.Config[];
106
+ export { crossFileInputs } from "./lib/cross-file-inputs.js";
107
+ export type { CrossFileInput } from "./lib/cross-file-inputs.js";
108
+ export { projectGraph } from "./project-graph.js";
109
+ export type { ProjectGraphFinding, ProjectGraphSnapshot } from "./project-graph.js";
package/dist/index.js CHANGED
@@ -306,3 +306,6 @@ for (const addition of A14_CONTRIBUTIONS) {
306
306
  Object.assign(rules, addition.rules);
307
307
  Object.assign(recommended, addition.recommended);
308
308
  }
309
+ // The result cache of `starci app lint` (contexts/project/check-cache.md) reads these two declarations of the installed canon.
310
+ export { crossFileInputs } from "./lib/cross-file-inputs.js";
311
+ export { projectGraph } from "./project-graph.js";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The files a rule of this canon reads that ESLint's own import graph does not carry.
3
+ *
4
+ * The result cache of `starci app lint` (contexts/project/check-cache.md, ESLint) re-lints a file when it or a file it reaches through imports
5
+ * changed. A rule that reads another file by path, with `fs` (a sibling, a directory listing, a manifest, a module file it follows by hand),
6
+ * has an input the import graph cannot see, so the rule declares it here and the cache dirties the files that depend on it:
7
+ *
8
+ * - `content`: the rule reads the bytes of the files the globs match; a changed, added or removed match dirties the dependants.
9
+ * - `exists`: the rule only asks whether a file is there or lists a folder; an added or removed match dirties the dependants.
10
+ * - `git`: the rule compares the file with git history (the merge-base with the base branch), which no file holds; a change of the merge-base
11
+ * dirties every dependant of more than `minLines` lines (the rule reads history only for a file over its size budget). `globs` is empty.
12
+ *
13
+ * `globs` are relative to the linted file's folder (`relativeTo: "file"`) or to the side root, the folder of the side's `eslint.config.mjs`
14
+ * (`relativeTo: "side"`); they use `*`, `**`, `?` and `{a,b}`, posix separators. `appliesTo` are side-root globs of the linted files that
15
+ * depend on the input. A declaration may be wider than the read, never narrower: a too wide one costs a lint, a too narrow one a wrong answer.
16
+ * `cross-file-inputs.spec.ts` proves the list complete: every source file that reads the disk is named here or accounted for, and a recorder of
17
+ * file reads checks each rule's real reads against these declarations and the import graph.
18
+ */
19
+ /** One input of a rule that the import graph does not carry. */
20
+ export interface CrossFileInput {
21
+ /** The published rule name (without the plugin prefix). */
22
+ readonly rule: string;
23
+ readonly kind: "content" | "exists" | "git";
24
+ readonly relativeTo: "file" | "side";
25
+ readonly globs: readonly string[];
26
+ /** For `git`: the line count above which the rule reads history; a file of fewer lines does not depend on it. */
27
+ readonly minLines?: number;
28
+ /** The linted files (side-root globs) whose result depends on the input. */
29
+ readonly appliesTo: readonly string[];
30
+ /** The source file of this package (relative to `src/`) that performs the read; the audit checks it exists and that no reader is missing. */
31
+ readonly reader: string;
32
+ }
33
+ /** The line count above which `file-size-growth` reads history: the soft budget of the shipped manifest (500) is never lower than this. */
34
+ export declare const HISTORY_MIN_LINES = 400;
35
+ /** Every disk read of this canon's rules beyond the import graph. */
36
+ export declare const crossFileInputs: readonly CrossFileInput[];
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The files a rule of this canon reads that ESLint's own import graph does not carry.
3
+ *
4
+ * The result cache of `starci app lint` (contexts/project/check-cache.md, ESLint) re-lints a file when it or a file it reaches through imports
5
+ * changed. A rule that reads another file by path, with `fs` (a sibling, a directory listing, a manifest, a module file it follows by hand),
6
+ * has an input the import graph cannot see, so the rule declares it here and the cache dirties the files that depend on it:
7
+ *
8
+ * - `content`: the rule reads the bytes of the files the globs match; a changed, added or removed match dirties the dependants.
9
+ * - `exists`: the rule only asks whether a file is there or lists a folder; an added or removed match dirties the dependants.
10
+ * - `git`: the rule compares the file with git history (the merge-base with the base branch), which no file holds; a change of the merge-base
11
+ * dirties every dependant of more than `minLines` lines (the rule reads history only for a file over its size budget). `globs` is empty.
12
+ *
13
+ * `globs` are relative to the linted file's folder (`relativeTo: "file"`) or to the side root, the folder of the side's `eslint.config.mjs`
14
+ * (`relativeTo: "side"`); they use `*`, `**`, `?` and `{a,b}`, posix separators. `appliesTo` are side-root globs of the linted files that
15
+ * depend on the input. A declaration may be wider than the read, never narrower: a too wide one costs a lint, a too narrow one a wrong answer.
16
+ * `cross-file-inputs.spec.ts` proves the list complete: every source file that reads the disk is named here or accounted for, and a recorder of
17
+ * file reads checks each rule's real reads against these declarations and the import graph.
18
+ */
19
+ /** The line count above which `file-size-growth` reads history: the soft budget of the shipped manifest (500) is never lower than this. */
20
+ export const HISTORY_MIN_LINES = 400;
21
+ /** Every disk read of this canon's rules beyond the import graph. */
22
+ export const crossFileInputs = [
23
+ // the manifest name of the package that owns the linted file (the nearest package.json upward)
24
+ { rule: "no-grammar-shim", kind: "content", relativeTo: "side", globs: ["**/package.json", "../package.json"], appliesTo: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], reader: "grammar-shim.ts" },
25
+ // an operation import is told from a workspace package by that package's manifest and entry
26
+ { rule: "swr-only-in-family", kind: "content", relativeTo: "side", globs: ["packages/*/package.json", "packages/*/src/**/*.{ts,tsx}"], appliesTo: ["**/*.{ts,tsx}"], reader: "swr-family.ts" },
27
+ { rule: "operations-only-in-swr-hooks", kind: "content", relativeTo: "side", globs: ["packages/*/package.json", "packages/*/src/**/*.{ts,tsx}"], appliesTo: ["**/*.{ts,tsx}"], reader: "swr-family.ts" },
28
+ { rule: "subscription-refetches-snapshot", kind: "content", relativeTo: "side", globs: ["packages/*/package.json", "packages/*/src/**/*.{ts,tsx}"], appliesTo: ["**/*.{ts,tsx}"], reader: "swr-family.ts" },
29
+ // an over-budget file is compared with its size at the merge-base with the base branch
30
+ { rule: "file-size-growth", kind: "git", relativeTo: "side", globs: [], minLines: HISTORY_MIN_LINES, appliesTo: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], reader: "size-growth.ts" },
31
+ ];
@@ -0,0 +1,10 @@
1
+ import type { AnyContext, Node, Rule } from "./rule.js";
2
+ /** True when a reported node is a raw element (or its attribute) inside a Freestyle subtree. */
3
+ export declare const isFreestyleRaw: (context: AnyContext, node: Node) => boolean;
4
+ /**
5
+ * The context a rule is given inside the exemption: the same context, whose `report` drops a finding about a raw element in a
6
+ * Freestyle subtree. Every other property, and every other finding, is the original's.
7
+ */
8
+ export declare const freestyleContext: <C extends object>(context: C) => C;
9
+ /** A canon rule that leaves the raw elements of a Freestyle subtree alone. */
10
+ export declare const exemptFreestyle: <M extends string, O extends readonly unknown[]>(rule: Rule<M, O>) => Rule<M, O>;
@@ -0,0 +1,87 @@
1
+ import { isNode } from "./ast.js";
2
+ import { importOf } from "./bindings.js";
3
+ /** The Grammar package entries a component can be imported from. */
4
+ const GRAMMAR_SOURCE = /^@starci\/grammar(?:\/(?:common|core|offset-pop))?$/;
5
+ /** The element name of a JSX name: `Freestyle`, or `G.Freestyle` as `Freestyle`. */
6
+ const nameOf = (name) => {
7
+ if (name.type === "JSXIdentifier")
8
+ return name.name;
9
+ if (name.type === "JSXMemberExpression")
10
+ return name.property.name;
11
+ return null;
12
+ };
13
+ /** True for a `Freestyle` element name. */
14
+ const isFreestyleName = (name) => nameOf(name) === "Freestyle";
15
+ /** The opening element a node is about: the node itself, its attribute's element, or the nearest enclosing element. */
16
+ const owningOpening = (node) => {
17
+ for (let current = node; current !== undefined && current !== null; current = current.parent) {
18
+ if (current.type === "JSXOpeningElement")
19
+ return current;
20
+ if (current.type === "JSXElement")
21
+ return current.openingElement;
22
+ }
23
+ return null;
24
+ };
25
+ /** The leftmost identifier of a JSX name (`G` of `G.Item`), or null for a namespaced name. */
26
+ const rootOf = (name) => {
27
+ if (name.type === "JSXIdentifier")
28
+ return name;
29
+ if (name.type === "JSXMemberExpression")
30
+ return rootOf(name.object);
31
+ return null;
32
+ };
33
+ /** True when the element is a Grammar component other than Freestyle: judged normally. */
34
+ const isGrammarComponent = (context, opening) => {
35
+ if (isFreestyleName(opening.name))
36
+ return false;
37
+ const root = rootOf(opening.name);
38
+ if (root === null || !/^[A-Z]/.test(root.name))
39
+ return false;
40
+ const origin = importOf(context, root);
41
+ return origin !== null && GRAMMAR_SOURCE.test(origin.source);
42
+ };
43
+ /** True when the opening element is a Freestyle element or sits inside one. */
44
+ const inFreestyle = (opening) => {
45
+ if (isFreestyleName(opening.name))
46
+ return true;
47
+ for (let current = opening.parent?.parent; current !== undefined && current !== null; current = current.parent) {
48
+ if (current.type === "JSXElement" && isFreestyleName(current.openingElement.name))
49
+ return true;
50
+ }
51
+ return false;
52
+ };
53
+ /** True when a reported node is a raw element (or its attribute) inside a Freestyle subtree. */
54
+ export const isFreestyleRaw = (context, node) => {
55
+ const opening = owningOpening(node);
56
+ return opening !== null && inFreestyle(opening) && !isGrammarComponent(context, opening);
57
+ };
58
+ /** The node a report descriptor is about, or null when it names only a location. */
59
+ const reportedNode = (descriptor) => {
60
+ if (typeof descriptor !== "object" || descriptor === null)
61
+ return null;
62
+ const node = Reflect.get(descriptor, "node");
63
+ return isNode(node) ? node : null;
64
+ };
65
+ /** True for a rule context that can resolve an import (it carries the source code). */
66
+ const readsImports = (context) => "sourceCode" in context;
67
+ /**
68
+ * The context a rule is given inside the exemption: the same context, whose `report` drops a finding about a raw element in a
69
+ * Freestyle subtree. Every other property, and every other finding, is the original's.
70
+ */
71
+ export const freestyleContext = (context) => {
72
+ const report = Reflect.get(context, "report");
73
+ if (typeof report !== "function")
74
+ return context;
75
+ const judged = (descriptor, ...rest) => {
76
+ const node = reportedNode(descriptor);
77
+ if (node !== null && readsImports(context) && isFreestyleRaw(context, node))
78
+ return undefined;
79
+ return Reflect.apply(report, context, [descriptor, ...rest]);
80
+ };
81
+ return Object.create(context, { report: { value: judged, enumerable: true } });
82
+ };
83
+ /** A canon rule that leaves the raw elements of a Freestyle subtree alone. */
84
+ export const exemptFreestyle = (rule) => ({
85
+ ...rule,
86
+ create: (context) => rule.create(freestyleContext(context)),
87
+ });
@@ -12,6 +12,7 @@
12
12
  * control. If the grammar lacks the control, the fix is to add it to the grammar, not to draw one here.
13
13
  */
14
14
  import { defineRule } from "./lib/rule.js";
15
+ import { exemptFreestyle } from "./lib/freestyle.js";
15
16
  import { attribute } from "./lib/ast.js";
16
17
  /** Intrinsic elements a reader operates, that the grammar renders for the product. */
17
18
  const NATIVE = new Set(["select", "input", "textarea", "button"]);
@@ -37,7 +38,7 @@ export const noNativeFormControl = defineRule({
37
38
  });
38
39
  // -- NATIVE-2 --------------------------------------------------------------------------------------
39
40
  /** No bare `<img>`; the framework's image owns size, format, lazy loading and layout shift. */
40
- export const noNativeImg = defineRule({
41
+ export const noNativeImg = exemptFreestyle(defineRule({
41
42
  meta: {
42
43
  type: "problem",
43
44
  docs: { description: "Use `next/image`, never a bare `<img>`." },
@@ -54,7 +55,7 @@ export const noNativeImg = defineRule({
54
55
  },
55
56
  };
56
57
  },
57
- });
58
+ }));
58
59
  // -- NATIVE-3 --------------------------------------------------------------------------------------
59
60
  /** Every `next/image` reserves its space: a size, or `fill` inside a sized parent with `sizes`. */
60
61
  export const imageHasSize = defineRule({
@@ -3,3 +3,30 @@ import type { Recommended, Rules } from "./lib/rule.js";
3
3
  export declare const rules: Rules;
4
4
  /** Every one at error. */
5
5
  export declare const recommended: Recommended;
6
+ /** One finding of the project graph on a file, as the rule of its enforcer reports it. Lines and columns count from 1, the way the machine names them. */
7
+ export interface ProjectGraphFinding {
8
+ readonly rule: string;
9
+ readonly code: string;
10
+ readonly line: number;
11
+ readonly column: number;
12
+ readonly message: string;
13
+ }
14
+ /** The project-graph findings of one side, by side-relative posix path, each list sorted by line, column, code and message. */
15
+ export interface ProjectGraphSnapshot {
16
+ readonly files: number;
17
+ readonly byFile: Readonly<Record<string, readonly ProjectGraphFinding[]>>;
18
+ }
19
+ /**
20
+ * The findings every project-graph rule of this canon reports, per file, from one fresh build of the side's graph.
21
+ *
22
+ * The result cache of `starci app lint` calls this once for a side whose files changed and compares each file's findings with the recorded
23
+ * ones: a file whose graph findings differ is linted again by ESLint itself, so ESLint stays the only emitter of each message. It is the very
24
+ * graph the rules read (same machine, same manifest, same filter), never a second judge.
25
+ *
26
+ * @param input - The side folder (`repoRoot`, the folder of the side's `eslint.config.mjs`); `injectedTypeScript` is for hermetic fixtures only.
27
+ * @returns The findings of the rules of `recommended`, keyed by path relative to the side folder.
28
+ */
29
+ export declare const projectGraph: ({ repoRoot, injectedTypeScript }: {
30
+ repoRoot: string;
31
+ injectedTypeScript?: object;
32
+ }) => ProjectGraphSnapshot;
@@ -7,7 +7,8 @@
7
7
  * rules and their codes is `LINT_ENFORCERS` of the machine (scripts/hfs/architecture/surface.mjs); `starci app check` keeps the
8
8
  * findings that have no TypeScript file to sit on. A graph that cannot be built stops the run with the machine's message.
9
9
  */
10
- import { LINT_ENFORCERS } from "#runtime/scripts/hfs/architecture/surface.mjs";
10
+ import { LINT_ENFORCERS, belongsTo, codeOf } from "#runtime/scripts/hfs/architecture/surface.mjs";
11
+ import { projectGraph as buildProjectGraph, resetProjectGraphs } from "#runtime/scripts/hfs/project-graph.mjs";
11
12
  import { projectRule } from "#runtime/scripts/hfs/project-rule.mjs";
12
13
  import { hfsOf } from "./lib/hfs.js";
13
14
  import { RUNTIME_ROOT } from "./lib/package-root.js";
@@ -17,3 +18,31 @@ const mine = LINT_ENFORCERS.filter((enforcer) => enforcer.plugins.includes("fe")
17
18
  export const rules = Object.fromEntries(mine.map((e) => [e.id, projectRule({ enforcer: e, runtimeRoot: RUNTIME, hfsOf })]));
18
19
  /** Every one at error. */
19
20
  export const recommended = Object.fromEntries(mine.map((e) => [`starci-fe/${e.id}`, "error"]));
21
+ /**
22
+ * The findings every project-graph rule of this canon reports, per file, from one fresh build of the side's graph.
23
+ *
24
+ * The result cache of `starci app lint` calls this once for a side whose files changed and compares each file's findings with the recorded
25
+ * ones: a file whose graph findings differ is linted again by ESLint itself, so ESLint stays the only emitter of each message. It is the very
26
+ * graph the rules read (same machine, same manifest, same filter), never a second judge.
27
+ *
28
+ * @param input - The side folder (`repoRoot`, the folder of the side's `eslint.config.mjs`); `injectedTypeScript` is for hermetic fixtures only.
29
+ * @returns The findings of the rules of `recommended`, keyed by path relative to the side folder.
30
+ */
31
+ export const projectGraph = ({ repoRoot, injectedTypeScript }) => {
32
+ resetProjectGraphs();
33
+ const graph = buildProjectGraph({ repoRoot, ...(injectedTypeScript === undefined ? {} : { injectedTypeScript }), runtimeRoot: RUNTIME });
34
+ const byFile = {};
35
+ for (const [file, findings] of graph.byFile) {
36
+ for (const finding of findings) {
37
+ for (const enforcer of mine.filter((entry) => belongsTo(entry, finding))) {
38
+ const line = finding.line !== undefined && Number.isInteger(finding.line) && finding.line >= 1 ? finding.line : 1;
39
+ const column = finding.column !== undefined && Number.isInteger(finding.column) && finding.column >= 1 ? finding.column : 1;
40
+ (byFile[file] ??= []).push({ rule: `starci-fe/${enforcer.id}`, code: String(codeOf(finding)), line, column, message: String(finding.message) });
41
+ }
42
+ }
43
+ }
44
+ const order = (a, b) => a.line - b.line || a.column - b.column || a.code.localeCompare(b.code) || a.message.localeCompare(b.message);
45
+ for (const list of Object.values(byFile))
46
+ list.sort(order);
47
+ return { files: graph.files, byFile };
48
+ };
@@ -4,6 +4,7 @@ import { functionOf, importOf, isFn, patternPath, unwrap, variableOf } from "./l
4
4
  import { typedClassStrings, typedPropertyStrings } from "./lib/typed-class-values.js";
5
5
  import { reactHookOf } from "./lib/react.js";
6
6
  import { defineRule } from "./lib/rule.js";
7
+ import { exemptFreestyle } from "./lib/freestyle.js";
7
8
  /** True when `text` is a string among `values`; any other value never is. */
8
9
  const isOneOf = (values, text) => typeof text === "string" && values.includes(text);
9
10
  /** The name a member expression reads: its static computed text or its identifier. */
@@ -316,7 +317,7 @@ export const nestedControlVariant = defineRule({
316
317
  },
317
318
  });
318
319
  /** App classes carry layout; Grammar owns surface paint. */
319
- export const noAppSurfacePaint = defineRule({
320
+ export const noAppSurfacePaint = exemptFreestyle(defineRule({
320
321
  meta: {
321
322
  type: "problem",
322
323
  docs: { description: "App composition CSS is layout-only; Grammar paints surfaces." },
@@ -377,9 +378,9 @@ export const noAppSurfacePaint = defineRule({
377
378
  },
378
379
  };
379
380
  },
380
- });
381
+ }));
381
382
  /** PADDING-0: app padding wrappers cannot add a second horizontal inset to Grammar's list or section caption. */
382
- export const noListPanelPadding = defineRule({
383
+ export const noListPanelPadding = exemptFreestyle(defineRule({
383
384
  meta: {
384
385
  type: "problem",
385
386
  docs: { description: "PADDING-0: panel lists and section captions share the Grammar surface's content edge." },
@@ -412,7 +413,7 @@ export const noListPanelPadding = defineRule({
412
413
  },
413
414
  };
414
415
  },
415
- });
416
+ }));
416
417
  /** BOUNDARY-1 case-5: only content bands carry inset; horizontal separators reach the surface edges. */
417
418
  export const dividerFullBleed = defineRule({
418
419
  meta: {
package/dist/tokens.js CHANGED
@@ -1,12 +1,13 @@
1
1
  /** Universal closed-token discipline, independent of a Grammar scale. */
2
2
  import { classTextVisitors } from "./lib/class-text.js";
3
3
  import { defineRule } from "./lib/rule.js";
4
+ import { exemptFreestyle } from "./lib/freestyle.js";
4
5
  /** A bracketed length: a value chosen once, for one screen, findable by nobody. */
5
6
  const ARBITRARY_LENGTH = /\b(?:gap|gap-x|gap-y|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|space-x|space-y|size|w|h|min-w|min-h|max-w|max-h)-\[[^\]]+\]/;
6
7
  /** A raw colour, which escapes the semantic palette even when it matches it today. */
7
8
  const RAW_COLOUR = /\b(?:text|bg|border|ring|from|to|via|fill|stroke|shadow|decoration)-\[#[0-9a-fA-F]/;
8
9
  /** An arbitrary value escapes the system, whatever it happens to evaluate to. */
9
- export const noArbitraryValue = defineRule({
10
+ export const noArbitraryValue = exemptFreestyle(defineRule({
10
11
  meta: {
11
12
  type: "problem",
12
13
  docs: { description: "Bracketed lengths and raw colours escape the closed vocabulary." },
@@ -28,7 +29,7 @@ export const noArbitraryValue = defineRule({
28
29
  context.report({ node, messageId: "colour", data: { cls: colour[0] } });
29
30
  });
30
31
  },
31
- });
32
+ }));
32
33
  /** Rules of universal token discipline. */
33
34
  export const rules = { "no-arbitrary-value": noArbitraryValue };
34
35
  /** Every token-discipline rule is mandatory. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-fe",
3
- "version": "9.6.2",
3
+ "version": "9.6.5",
4
4
  "description": "Universal front-end lint laws for TypeScript and Next.js, independent of the selected Grammar family.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -61,7 +61,7 @@
61
61
  "scripts": {
62
62
  "build": "node -e \"require('node:fs').rmSync('dist', {recursive: true, force: true})\" && tsc -p tsconfig.build.json && node -e \"const fs = require('node:fs'); for (const f of fs.readdirSync('dist', {recursive: true}).filter((n) => n.endsWith('.d.ts'))) fs.writeFileSync('dist/' + f, fs.readFileSync('dist/' + f, 'utf8').replace(/((?:from |import\\()['\\x22]\\.\\.?\\/[^'\\x22]+)\\.ts(['\\x22])/g, (all, head, quote) => head + '.js' + quote))\"",
63
63
  "typecheck": "tsc -p tsconfig.json",
64
- "test": "node --test \"src/**/*.spec.ts\"",
64
+ "test": "node --import ./fixtures/fs-audit-preload.ts --test \"src/**/*.spec.ts\"",
65
65
  "prepack": "npm run typecheck && npm run build && npm test"
66
66
  },
67
67
  "keywords": [
@@ -32,6 +32,7 @@
32
32
  "scripts/api/git/remote.mjs",
33
33
  "scripts/api/git/rev-parse-query.mjs",
34
34
  "scripts/api/git/show.mjs",
35
+ "scripts/api/git/tag.mjs",
35
36
  "scripts/hfs/allows.mjs",
36
37
  "scripts/hfs/architecture/ast-walks.mjs",
37
38
  "scripts/hfs/architecture/automatic-gates.mjs",
@@ -31,42 +31,42 @@ pins:
31
31
  source: packages/event-stream/package.json
32
32
  why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
33
33
  '@starci/grammar':
34
- version: 0.16.2
34
+ version: 0.16.5
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/grammar/package.json
39
39
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
40
40
  '@starci/eslint-canon-be':
41
- version: 3.9.2
41
+ version: 3.10.0
42
42
  group: starci
43
43
  install: registry
44
44
  side: be
45
45
  source: packages/eslint-canon-be/package.json
46
46
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
47
47
  '@starci/eslint-canon-fe':
48
- version: 9.6.2
48
+ version: 9.6.5
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/eslint-canon-fe/package.json
53
53
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
54
54
  '@starci/eslint-grammar-core':
55
- version: 0.16.2
55
+ version: 0.16.5
56
56
  group: starci
57
57
  install: registry
58
58
  side: fe
59
59
  source: packages/eslint-grammar-core/package.json
60
60
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
61
61
  '@starci/eslint-grammar-offset-pop':
62
- version: 0.16.2
62
+ version: 0.16.5
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
66
66
  source: packages/eslint-grammar-offset-pop/package.json
67
67
  why: 'the Offset Pop Grammar component API lint, versioned with @starci/grammar; its shared component contract comes from the StarCi family lint without copying rules.'
68
68
  '@starci/stylelint-canon':
69
- version: 2.2.1
69
+ version: 2.3.0
70
70
  group: starci
71
71
  install: registry
72
72
  side: fe
@@ -85,7 +85,7 @@ pins:
85
85
  side: both
86
86
  source: packages/prettier-config/package.json
87
87
  '@starci/jest-preset':
88
- version: 2.5.3
88
+ version: 2.6.0
89
89
  group: starci
90
90
  install: registry
91
91
  side: be
@@ -106,7 +106,7 @@ pins:
106
106
  source: packages/ai-router/package.json
107
107
  why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
108
108
  '@starci/hfs':
109
- version: 4.12.2
109
+ version: 4.13.2
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -1956,7 +1956,7 @@ slots:
1956
1956
  tests: unit-beside # <name>.service.spec.ts beside each <name>.service.ts; plus src/tests/integration/<provider>/ (R112)
1957
1957
  coverage: required
1958
1958
  budget: {indexExports: 80} # a provider's port surface includes clients, config, options, tokens and errors
1959
- rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INTEGRATION_SPEC_MISSING, BE_AI_KEY_CUSTODY, BE_AI_GATEWAY_CLIENT]
1959
+ rules: [BE_TIER_DIRECTION, BE_ERROR_HOME, BE_SECRET_DEFAULT, BE_INTEGRATION_SPEC_MISSING, BE_AI_KEY_CUSTODY, BE_AI_GATEWAY_CLIENT, BE_AI_VIRTUAL_KEY]
1960
1960
  - id: be.integrations.model
1961
1961
  since: 3.1.0
1962
1962
  profiles: [be]
@@ -0,0 +1,4 @@
1
+ // tag.mjs — `git tag <args>`: list or create tags (--points-at <rev>, -l <pattern>, -a <name> -m <message>).
2
+ import { runGit } from './lib.mjs';
3
+ /** The spawn result {status, stdout, stderr, error} of `git tag ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
4
+ export const tag = (args = [], options = {}) => runGit(['tag', ...args], options);
@@ -3,6 +3,7 @@
3
3
  import { lsTree } from '../../api/git/ls-tree.mjs';
4
4
  import { mergeBase } from '../../api/git/merge-base.mjs';
5
5
  import { show } from '../../api/git/show.mjs';
6
+ import { tag } from '../../api/git/tag.mjs';
6
7
  import { withoutGitLocalEnv } from '../../lib/git.mjs';
7
8
  import { eachInOrder, findInOrder } from '../../lib/in-order.mjs';
8
9
  import { sameText } from '../../lib/same-text.mjs';
@@ -37,12 +38,21 @@ export function defaultGit(args, { cwd }) {
37
38
  const sha = mergeBase(cwd, rest[0], rest[1]);
38
39
  return { ok: sha !== null, stdout: sha ?? '' };
39
40
  }
41
+ if (verb === 'previous-release')
42
+ return previousReleaseTag(cwd, env, rest[0]);
40
43
  let call = show;
41
44
  if (verb === 'ls-tree')
42
45
  call = lsTree;
43
46
  const result = call(rest, { cwd, env, maxBuffer: 64 * 1024 * 1024 });
44
47
  return { ok: !result.error && result.status === 0, stdout: result.stdout };
45
48
  }
49
+ /** The nearest release tag before HEAD: the newest version among the tags HEAD contains that do not point at HEAD itself. `pattern` is a tag glob (v*). */
50
+ function previousReleaseTag(cwd, env, pattern) {
51
+ const listed = (args) => { const result = tag(args, { cwd, env }); return !result.error && result.status === 0 ? String(result.stdout).split(/\r?\n/).map((line) => line.trim()).filter(Boolean) : []; };
52
+ const here = new Set(listed(['--points-at', 'HEAD']));
53
+ const previous = listed(['--merged', 'HEAD', '--list', pattern, '--sort=-v:refname']).find((name) => !here.has(name));
54
+ return { ok: previous !== undefined, stdout: previous ?? '' };
55
+ }
46
56
  const runGit = (git, repoRoot, args) => Promise.resolve(git(args, { cwd: repoRoot }))
47
57
  .then((result) => ({ ok: result?.ok === true, stdout: String(result?.stdout ?? '') }));
48
58
  async function baseShaOf(git, repoRoot, base) {
@@ -515,6 +525,51 @@ function destructiveFileFindings(file, text, references) {
515
525
  return finding ? [finding] : [];
516
526
  });
517
527
  }
528
+ const BE_SQL_SOURCE = /^be\/src\/.+\.(?:sql|entity)\.[cm]?tsx?$/;
529
+ /** The tag glob of a release (release.tagPrefix defaults to v). */
530
+ const RELEASE_TAG_GLOB = 'v*';
531
+ /**
532
+ * A contract migration must not ship in the same release as the code change it depends on (cicd.md, Database migrations). The deploy applies
533
+ * a contract migration right after the accept, while the release it replaced is still the rollback target: if that release's code still names
534
+ * the destroyed object, a rollback after the contract would run code against a schema that no longer has it. The check judges exactly that
535
+ * with git: each contract migration that is new since the previous release tag (absent there) is read for the objects it destroys, and the
536
+ * be/src sources AS THEY WERE AT THE PREVIOUS RELEASE TAG must not reference them (the sources at HEAD are judged by destructiveMigrationFindings).
537
+ * The first release, a repository without a release tag, or an unreadable git history has no previous release and no finding. It reports
538
+ * DB_MIGRATION_DESTRUCTIVE (R259), a hard blocker: ship the code change in one release and its contract migration in the next.
539
+ */
540
+ export async function contractReleaseFindings({ repoRoot, migrations, git }) {
541
+ const contracts = migrations.filter((file) => /_contract_[a-z0-9]+(?:[-_][a-z0-9]+)*\.sql$/.test(file));
542
+ if (!contracts.length)
543
+ return [];
544
+ const previous = await runGit(git, repoRoot, ['previous-release', RELEASE_TAG_GLOB]);
545
+ const release = previous.stdout.trim();
546
+ if (!previous.ok || !release)
547
+ return [];
548
+ const known = new Set(await migrationFilesAt(git, repoRoot, release));
549
+ const fresh = contracts.filter((file) => !known.has(file));
550
+ if (!fresh.length)
551
+ return [];
552
+ const listing = await runGit(git, repoRoot, ['ls-tree', '-r', '--name-only', '-z', release, '--', 'be/src']);
553
+ const sources = listing.ok ? listing.stdout.split('\0').filter((file) => BE_SQL_SOURCE.test(file)) : [];
554
+ if (!sources.length)
555
+ return [];
556
+ const ts = loadTypescript(repoRoot, HERE);
557
+ if (!ts)
558
+ return [found(DB_MIGRATION_DESTRUCTIVE, 'hfs.json', 'the contract-release check requires the TypeScript parser to read the be/src references of the previous release')];
559
+ const references = [];
560
+ await eachInOrder(sources, async (file) => {
561
+ references.push({ file, scopes: sourceNameScopes(ts, await baseFileText(git, repoRoot, release, file) ?? '', file) });
562
+ });
563
+ return fresh.flatMap((file) => {
564
+ const text = readText(repoRoot, file);
565
+ if (text === null)
566
+ return [];
567
+ return databaseSqlStatements(databaseExecutableTokens(text)).flatMap(destructiveObjects).flatMap((object) => {
568
+ const reference = references.find(({ scopes }) => referencesObject(scopes, object));
569
+ return reference ? [found(DB_MIGRATION_DESTRUCTIVE, file, `${file}:${object.line} destroys ${object.name}, which the previous release ${release} still references in ${reference.file}; the contract migration runs right after the accept while that release is the rollback target: ship the code change in one release and this contract migration in the next`, { line: object.line, object: object.name, release, reference: reference.file })] : [];
570
+ });
571
+ });
572
+ }
518
573
  /** Destructive changes require a contract migration and no remaining SQL/entity reference in be/src. */
519
574
  export function destructiveMigrationFindings({ repoRoot, migrations, files }) {
520
575
  const sources = files.filter((file) => /^be\/src\/.+\.(?:sql|entity)\.[cm]?tsx?$/.test(file));