@starci/hfs 4.7.7 → 4.7.8

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.7.8 - 2026-10-19
4
+ - Fixed: catalog usage follows ordered object and JSX spreads, readonly class translator fields and their declared forwarding methods while unknown or overwritten producers remain barriers.
5
+ - Fixed: strict-null narrowing retains initializer provenance only for an unmodified const reference; destructuring writes and ancestor replacements invalidate the proof across the containing evaluation frame.
6
+ - Fixed: finite template catalog keys resolve from bounded tuple and callback values, with unknown spans and excessive products rejected; a proven catalog leaf consumes only its same-bundle locale siblings.
7
+
3
8
  ## 4.7.7 - 2026-10-19
4
9
  - Changed: bundled canon pins follow the front-end canon's module classification correction in 8.4.13; HFS command behavior is unchanged.
5
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.7.7",
3
+ "version": "4.7.8",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,14 +29,14 @@ pins:
29
29
  source: packages/grammar/package.json
30
30
  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.'
31
31
  '@starci/eslint-canon-be':
32
- version: 3.4.13
32
+ version: 3.4.14
33
33
  group: starci
34
34
  install: registry
35
35
  side: be
36
36
  source: packages/eslint/be/package.json
37
37
  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.'
38
38
  '@starci/eslint-canon-fe':
39
- version: 8.4.13
39
+ version: 8.4.14
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -76,14 +76,14 @@ pins:
76
76
  source: packages/test-world/package.json
77
77
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
78
78
  '@starci/cli':
79
- version: 1.7.7
79
+ version: 1.7.8
80
80
  group: starci
81
81
  install: registry
82
82
  side: both
83
83
  source: packages/cli/package.json
84
84
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
85
85
  '@starci/hfs':
86
- version: 4.7.7
86
+ version: 4.7.8
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -2,52 +2,110 @@
2
2
  // followed through variables, parameters (state.arguments), array callbacks, for-of and literal tables.
3
3
  import { nameText as nameOf } from '../../lib/ts-ast.mjs';
4
4
  import { unwrapCatalogExpression as unwrap } from '../../lib/ts-analysis.mjs';
5
+ import { excludesNull, propertiesOf, propertyValues, writesTarget } from './unused-symbol-properties.mjs';
5
6
 
6
7
  const ARRAY_CALLBACK = /^(?:map|flatMap|forEach|filter|find|some|every)$/u;
7
8
 
9
+ /** A forwarded parameter must retain its value, including in a conditional guard. */
10
+ export function writesBinding(state, node, symbol) {
11
+ return writesTarget(state, node, target => state.ts.isIdentifier(target) && state.checker.getSymbolAtLocation(target) === symbol);
12
+ }
13
+
8
14
  export const symbolAt = (state, node) => {
9
15
  const symbol = state.ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node
10
16
  ? state.checker.getShorthandAssignmentValueSymbol(node.parent) : state.checker.getSymbolAtLocation(node);
11
17
  return symbol?.flags === state.ts.SymbolFlags.Alias ? state.checker.getAliasedSymbol(symbol) : symbol;
12
18
  };
13
19
 
14
- /** The expression bound to `name` in an object literal or a JSX attribute list. */
15
- function propertyValues(state, owner, name) {
20
+ /** Only a declared, unmodified class method or readonly field supplies instance-member provenance. */
21
+ function classMemberValue(state, declaration) {
16
22
  const { ts } = state;
17
- if (ts.isObjectLiteralExpression(owner)) return owner.properties.filter(entry => (ts.isPropertyAssignment(entry) || ts.isShorthandPropertyAssignment(entry)) && nameOf(ts, entry.name) === name).map(entry => ts.isShorthandPropertyAssignment(entry) ? entry.name : entry.initializer);
18
- if (!ts.isJsxAttributes(owner)) return [];
19
- const attributes = owner.properties.filter(entry => ts.isJsxAttribute(entry) && entry.name.text === name && entry.initializer);
20
- return attributes.map(entry => ts.isJsxExpression(entry.initializer) ? entry.initializer.expression : entry.initializer).filter(Boolean);
23
+ if (!declaration || !ts.isClassLike(declaration.parent)) return null;
24
+ const field = ts.isPropertyDeclaration(declaration);
25
+ if (!ts.isMethodDeclaration(declaration) && (!field || !declaration.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ReadonlyKeyword))) return null;
26
+ const mutations = state.memberMutations ??= new Map();
27
+ if (!mutations.has(declaration)) mutations.set(declaration, [...(state.flowSources?.values() ?? [declaration.getSourceFile()])]
28
+ .some(source => writesTarget(state, source, target => symbolAt(state, target)?.valueDeclaration === declaration)));
29
+ if (mutations.get(declaration)) return null;
30
+ return field ? unwrap(ts, declaration.initializer) : declaration;
21
31
  }
22
32
 
23
- /** `TABLE[key]`: the entry of a literal key, else every value of the table (any of them can be read). */
24
- function tableValues(state, node, seen) {
33
+ /** Local callable declarations and supported class fields share the same declaration resolution. */
34
+ export function callableValue(state, node) {
35
+ const declaration = symbolAt(state, node)?.valueDeclaration;
36
+ if (!declaration) return null;
37
+ if (state.ts.isVariableDeclaration(declaration)) return unwrap(state.ts, declaration.initializer);
38
+ return state.ts.isClassLike(declaration.parent) ? classMemberValue(state, declaration) : declaration;
39
+ }
40
+
41
+ /** Selected initializers retain their declaration identity for exact catalog consumption. */
42
+ export function propertyInitializers(state, expression, name) {
43
+ return propertiesOf(state, valuesOf(state, expression), name, new Set(), valuesOf);
44
+ }
45
+
46
+ function tableOwnerValues(state, owner, literal, seen) {
25
47
  const { ts } = state;
26
- const literal = ts.isStringLiteralLike(node.argumentExpression) ? node.argumentExpression.text : null;
27
- return valuesOf(state, node.expression, seen).flatMap(owner => {
28
- if (ts.isArrayLiteralExpression(owner)) return [...owner.elements];
29
- if (!ts.isObjectLiteralExpression(owner)) return [];
30
- if (literal !== null) return propertyValues(state, owner, literal);
31
- return owner.properties.filter(entry => ts.isPropertyAssignment(entry)).map(entry => entry.initializer);
32
- });
48
+ if (ts.isArrayLiteralExpression(owner)) return [...owner.elements];
49
+ if (!ts.isObjectLiteralExpression(owner)) return [];
50
+ if (literal !== null) return propertyValues(state, owner, literal, seen, valuesOf);
51
+ // A dynamic selection from a table with spread or computed members has unknown provenance.
52
+ if (owner.properties.some(entry => !ts.isPropertyAssignment(entry) || nameOf(ts, entry.name, { numbers: true }) === null)) return [];
53
+ return owner.properties.map(entry => entry.initializer);
54
+ }
55
+
56
+ /** A literal table key selects its property; a dynamic key admits only a completely known table. */
57
+ function tableValues(state, node, seen) {
58
+ const literal = nameOf(state.ts, node.argumentExpression, { identifiers: false, numbers: true });
59
+ const groups = valuesOf(state, node.expression, seen).map(owner => tableOwnerValues(state, owner, literal, seen));
60
+ return groups.length && groups.every(group => group.length) ? groups.flat() : [];
33
61
  }
34
62
 
35
63
  const resolveAll = (state, nodes, seen) => nodes.flatMap(node => valuesOf(state, node, seen));
36
64
 
37
65
  function elementsOf(state, expression, seen) {
38
- return valuesOf(state, expression, seen).flatMap(owner => state.ts.isArrayLiteralExpression(owner) ? [...owner.elements] : []);
66
+ const owners = valuesOf(state, expression, seen);
67
+ if (owners.some(owner => !state.ts.isArrayLiteralExpression(owner))) return [];
68
+ return owners.flatMap(owner => [...owner.elements]);
39
69
  }
40
70
 
41
- function parameterValues(state, declaration, seen) {
71
+ const parameterKey = (state, declaration) => state.ts.isIdentifier(declaration.name)
72
+ ? state.checker.getSymbolAtLocation(declaration.name) : declaration;
73
+
74
+ /** A recognized array callback binds its first parameter even when its collection values are unknown. */
75
+ function arrayCallbackSource(state, declaration) {
42
76
  const { ts } = state;
43
77
  const callback = declaration.parent;
44
78
  const call = callback.parent;
45
- if (ts.isCallExpression(call) && call.arguments[0] === callback && callback.parameters[0] === declaration
46
- && ts.isPropertyAccessExpression(call.expression) && ARRAY_CALLBACK.test(call.expression.name.text)) {
47
- return resolveAll(state, elementsOf(state, call.expression.expression, seen), seen);
48
- }
49
- const key = ts.isIdentifier(declaration.name) ? state.checker.getSymbolAtLocation(declaration.name) : declaration;
50
- return resolveAll(state, state.arguments.get(key) ?? [], seen);
79
+ if (!ts.isCallExpression(call) || call.arguments[0] !== callback || callback.parameters[0] !== declaration) return null;
80
+ const expression = call.expression;
81
+ return ts.isPropertyAccessExpression(expression) && ARRAY_CALLBACK.test(expression.name.text) ? expression.expression : null;
82
+ }
83
+
84
+ function parameterValues(state, declaration, seen) {
85
+ const collection = arrayCallbackSource(state, declaration);
86
+ if (collection) return resolveAll(state, elementsOf(state, collection, seen), seen);
87
+ return resolveAll(state, state.arguments.get(parameterKey(state, declaration)) ?? [], seen);
88
+ }
89
+
90
+ function declarationEvidence(state, declaration) {
91
+ if (!declaration) return false;
92
+ if (state.ts.isParameter(declaration))
93
+ return state.arguments.has(parameterKey(state, declaration)) || arrayCallbackSource(state, declaration) !== null;
94
+ if (!state.ts.isVariableDeclaration(declaration)) return false;
95
+ const loop = declaration.parent?.parent;
96
+ return Boolean(declaration.initializer) || Boolean(loop && state.ts.isForOfStatement(loop));
97
+ }
98
+
99
+ /** Separate unbound generic receivers from receivers whose observed producers failed namespace resolution. */
100
+ export function receiverBinding(state, expression) {
101
+ const { ts } = state;
102
+ let root = unwrap(ts, expression);
103
+ while (root && (ts.isPropertyAccessExpression(root) || ts.isElementAccessExpression(root))) root = unwrap(ts, root.expression);
104
+ const declaration = root && ts.isIdentifier(root) ? symbolAt(state, root)?.valueDeclaration : null;
105
+ const handedIn = Boolean(declaration) && (ts.isParameter(declaration) || ts.isBindingElement(declaration));
106
+ let owner = declaration;
107
+ while (owner && ts.isBindingElement(owner)) owner = owner.parent?.parent;
108
+ return { handedIn, observed: declarationEvidence(state, owner) };
51
109
  }
52
110
 
53
111
  function bindingValues(state, declaration, seen) {
@@ -57,10 +115,11 @@ function bindingValues(state, declaration, seen) {
57
115
  const owners = declarationValues(state, pattern.parent, seen);
58
116
  if (ts.isObjectBindingPattern(pattern)) {
59
117
  const name = nameOf(ts, declaration.propertyName ?? declaration.name);
60
- return resolveAll(state, owners.flatMap(owner => propertyValues(state, owner, name)), seen);
118
+ return resolveAll(state, propertiesOf(state, owners, name, seen, valuesOf), seen);
61
119
  }
62
120
  const index = pattern.elements.indexOf(declaration);
63
- return resolveAll(state, owners.flatMap(owner => ts.isArrayLiteralExpression(owner) && owner.elements[index] ? [owner.elements[index]] : []), seen);
121
+ if (owners.some(owner => !ts.isArrayLiteralExpression(owner) || !owner.elements[index])) return [];
122
+ return resolveAll(state, owners.map(owner => owner.elements[index]), seen);
64
123
  }
65
124
 
66
125
  function declarationValues(state, declaration, seen) {
@@ -76,11 +135,10 @@ function declarationValues(state, declaration, seen) {
76
135
  /** Resolve local function and memo callback returns; an unknown call remains its own leaf. */
77
136
  function callValues(state, node, seen) {
78
137
  const { ts } = state;
79
- const declaration = symbolAt(state, node.expression)?.valueDeclaration;
80
- const target = declaration && ts.isVariableDeclaration(declaration) ? unwrap(ts, declaration.initializer) : declaration;
138
+ const target = callableValue(state, node.expression);
81
139
  const memo = ts.isIdentifier(node.expression) && node.expression.text === 'useMemo' ? unwrap(ts, node.arguments[0]) : null;
82
140
  const fn = memo ?? target;
83
- return fn && (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn) || ts.isFunctionDeclaration(fn))
141
+ return fn && (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn) || ts.isFunctionDeclaration(fn) || ts.isMethodDeclaration(fn))
84
142
  ? resolveAll(state, returnedExpressions(ts, fn), seen) : [node];
85
143
  }
86
144
 
@@ -88,16 +146,24 @@ function callValues(state, node, seen) {
88
146
  export function valuesOf(state, expression, visited = new Set()) {
89
147
  const { ts } = state;
90
148
  const node = unwrap(ts, expression);
91
- if (!node || visited.has(node)) return [];
149
+ if (!node) return [];
150
+ if (visited.has(node)) return [node];
92
151
  const seen = new Set([...visited, node]);
152
+ const orLeaf = values => values.length ? values : [node];
153
+ const narrowed = values => orLeaf(excludesNull(state, node) ? values.filter(value => value.kind !== ts.SyntaxKind.NullKeyword) : values);
93
154
  if (ts.isConditionalExpression(node)) return [...valuesOf(state, node.whenTrue, seen), ...valuesOf(state, node.whenFalse, seen)];
94
- if (ts.isCallExpression(node)) return callValues(state, node, seen);
95
- if (ts.isPropertyAccessExpression(node)) return resolveAll(state, valuesOf(state, node.expression, seen).flatMap(owner => propertyValues(state, owner, node.name.text)), seen);
96
- if (ts.isElementAccessExpression(node)) return resolveAll(state, tableValues(state, node, seen), seen);
155
+ if (ts.isCallExpression(node)) return orLeaf(callValues(state, node, seen));
156
+ if (ts.isPropertyAccessExpression(node)) {
157
+ const member = classMemberValue(state, symbolAt(state, node)?.valueDeclaration);
158
+ if (member) return [member];
159
+ }
160
+ if (ts.isPropertyAccessExpression(node)) return narrowed(resolveAll(state, propertiesOf(state, valuesOf(state, node.expression, seen), node.name.text, seen, valuesOf), seen));
161
+ if (ts.isElementAccessExpression(node)) return orLeaf(resolveAll(state, tableValues(state, node, seen), seen));
97
162
  if (!ts.isIdentifier(node)) return [node];
98
163
  const symbol = symbolAt(state, node);
99
164
  if (!symbol?.valueDeclaration || state.translators.has(symbol)) return [node];
100
- return declarationValues(state, symbol.valueDeclaration, seen);
165
+ const values = declarationValues(state, symbol.valueDeclaration, seen);
166
+ return narrowed(values);
101
167
  }
102
168
 
103
169
  /** The expressions a function returns: its expression body, or the operand of each return outside nested functions. */
@@ -0,0 +1,162 @@
1
+ // Ordered object and JSX property provenance for R280; unknown writers remain barriers.
2
+ import { nameText } from '../../lib/ts-ast.mjs';
3
+ import { unwrapCatalogExpression as unwrap } from '../../lib/ts-analysis.mjs';
4
+
5
+ const ABSENT_PROPERTY = { values: [], missing: true };
6
+
7
+ /** A definite write replaces earlier provenance; a missing branch keeps it, and an unknown branch is a barrier. */
8
+ function mergeProperty(before, after) {
9
+ if (after === null || !after.missing) return after;
10
+ if (before === null) return null;
11
+ return { values: [...before.values, ...after.values], missing: before.missing };
12
+ }
13
+
14
+ /** Every possible spread owner must be known before its property provenance is admitted. */
15
+ function spreadProperty(state, expression, name, seen, resolve) {
16
+ const choices = resolve(state, expression, seen).map(owner => ownerProperty(state, owner, name, seen, resolve));
17
+ if (!choices.length || choices.some(choice => choice === null)) return null;
18
+ return { values: choices.flatMap(choice => choice.values), missing: choices.some(choice => choice.missing) };
19
+ }
20
+
21
+ function entryProperty(state, entry, name, seen, resolve) {
22
+ const { ts } = state;
23
+ if (ts.isSpreadAssignment(entry) || ts.isJsxSpreadAttribute(entry)) return spreadProperty(state, entry.expression, name, seen, resolve);
24
+ const key = nameText(ts, entry.name, { numbers: true });
25
+ if (key === null) return null;
26
+ if (key !== name) return ABSENT_PROPERTY;
27
+ if (ts.isPropertyAssignment(entry)) return { values: [entry.initializer], missing: false };
28
+ if (ts.isShorthandPropertyAssignment(entry)) return { values: [entry.name], missing: false };
29
+ if (ts.isJsxAttribute(entry)) {
30
+ const value = entry.initializer && (ts.isJsxExpression(entry.initializer) ? entry.initializer.expression : entry.initializer);
31
+ return value ? { values: [value], missing: false } : null;
32
+ }
33
+ // A method or accessor owns the name, but does not establish a supported translator value.
34
+ return null;
35
+ }
36
+
37
+ /** Fold writes in source order, with branch-local cycle detection and explicit absence. */
38
+ function ownerProperty(state, owner, name, visited, resolve) {
39
+ if ((!state.ts.isObjectLiteralExpression(owner) && !state.ts.isJsxAttributes(owner)) || visited.has(owner)) return null;
40
+ const seen = new Set([...visited, owner]);
41
+ return owner.properties.reduce((before, entry) => mergeProperty(before, entryProperty(state, entry, name, seen, resolve)), ABSENT_PROPERTY);
42
+ }
43
+
44
+ /** The expression bound to name in a known object literal or a JSX attribute list. */
45
+ export function propertyValues(state, owner, name, seen, resolve) {
46
+ const property = ownerProperty(state, owner, name, seen, resolve);
47
+ return property && !property.missing ? property.values : [];
48
+ }
49
+
50
+ /** Unknown or missing owner branches cannot disappear beside a known property branch. */
51
+ export function propertiesOf(state, owners, name, seen, resolve) {
52
+ const groups = owners.map(owner => propertyValues(state, owner, name, seen, resolve));
53
+ return groups.length && groups.every(group => group.length) ? groups.flat() : [];
54
+ }
55
+
56
+ function returnsAbruptly(ts, statement) {
57
+ const own = ts.isBlock(statement) && statement.statements.length === 1 ? statement.statements[0] : statement;
58
+ return ts.isReturnStatement(own) || ts.isThrowStatement(own);
59
+ }
60
+
61
+ function sameReference(state, left, right) {
62
+ const { ts, checker } = state;
63
+ const a = unwrap(ts, left), b = unwrap(ts, right);
64
+ if (!a || !b) return false;
65
+ if (ts.isIdentifier(a) && ts.isIdentifier(b)) return checker.getSymbolAtLocation(a) === checker.getSymbolAtLocation(b);
66
+ return ts.isPropertyAccessExpression(a) && ts.isPropertyAccessExpression(b)
67
+ && a.name.text === b.name.text && sameReference(state, a.expression, b.expression);
68
+ }
69
+
70
+ function comparesNull(state, expression, reference, negative = false) {
71
+ const { ts } = state;
72
+ const node = unwrap(ts, expression);
73
+ const operator = negative ? ts.SyntaxKind.ExclamationEqualsEqualsToken : ts.SyntaxKind.EqualsEqualsEqualsToken;
74
+ if (!node || !ts.isBinaryExpression(node) || node.operatorToken.kind !== operator) return false;
75
+ return [[node.left, node.right], [node.right, node.left]].some(([value, nil]) =>
76
+ nil.kind === ts.SyntaxKind.NullKeyword && sameReference(state, value, reference));
77
+ }
78
+
79
+ function immutableReference(state, expression) {
80
+ const { ts } = state;
81
+ let root = unwrap(ts, expression);
82
+ while (root && ts.isPropertyAccessExpression(root)) root = root.expression;
83
+ if (!root || !ts.isIdentifier(root)) return false;
84
+ let declaration = state.checker.getSymbolAtLocation(root)?.valueDeclaration;
85
+ while (declaration && ts.isBindingElement(declaration)) declaration = declaration.parent.parent;
86
+ return Boolean(declaration && ts.isVariableDeclaration(declaration) && (declaration.parent.flags & ts.NodeFlags.Const));
87
+ }
88
+
89
+ function assignmentTarget(state, expression, matches) {
90
+ const { ts } = state;
91
+ const node = unwrap(ts, expression);
92
+ if (!node) return false;
93
+ if (matches(node)) return true;
94
+ if (ts.isArrayLiteralExpression(node)) return node.elements.some(element => assignmentTarget(state, element, matches));
95
+ if (ts.isObjectLiteralExpression(node)) return node.properties.some(entry => assignmentTarget(state,
96
+ ts.isShorthandPropertyAssignment(entry) ? entry.name : entry.initializer ?? entry.expression, matches));
97
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) return assignmentTarget(state, node.left, matches);
98
+ return (ts.isSpreadElement(node) || ts.isSpreadAssignment(node)) && assignmentTarget(state, node.expression, matches);
99
+ }
100
+
101
+ /** All assignment forms share one target traversal; each consumer owns the reference it invalidates. */
102
+ export function writesTarget(state, node, matches) {
103
+ const { ts } = state;
104
+ let written = false;
105
+ const visit = own => {
106
+ const assignment = ts.isBinaryExpression(own) && own.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && own.operatorToken.kind <= ts.SyntaxKind.LastAssignment;
107
+ const unary = (ts.isPrefixUnaryExpression(own) || ts.isPostfixUnaryExpression(own)) && [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(own.operator);
108
+ if ((assignment && assignmentTarget(state, own.left, matches)) || (unary && assignmentTarget(state, own.operand, matches))
109
+ || ((ts.isForOfStatement(own) || ts.isForInStatement(own)) && assignmentTarget(state, own.initializer, matches))) written = true;
110
+ if (!written) ts.forEachChild(own, visit);
111
+ };
112
+ if (node) visit(node);
113
+ return written;
114
+ }
115
+
116
+ function writesReference(state, node, reference) {
117
+ return writesTarget(state, node, target => {
118
+ for (let own = unwrap(state.ts, reference); own; own = state.ts.isPropertyAccessExpression(own) ? unwrap(state.ts, own.expression) : null) {
119
+ if (sameReference(state, target, own)) return true;
120
+ }
121
+ return false;
122
+ });
123
+ }
124
+
125
+ function priorReferenceWrite(state, node) {
126
+ const { ts } = state;
127
+ for (let child = node; child.parent; child = child.parent) {
128
+ const parent = child.parent;
129
+ if (ts.isClassLike(parent)) break;
130
+ if (ts.isFunctionLike(parent)) return !ts.isBlock(child) && writesReference(state, child, node);
131
+ if (ts.isBlock(parent) && parent.statements.slice(0, parent.statements.indexOf(child) + 1).some(statement => writesReference(state, statement, node))) return true;
132
+ }
133
+ return false;
134
+ }
135
+
136
+ function conditionalExcludesNull(state, node) {
137
+ const { ts } = state;
138
+ for (let child = node; child.parent; child = child.parent) {
139
+ const parent = child.parent;
140
+ if (ts.isFunctionLike(parent) || ts.isClassLike(parent)) return false;
141
+ if (ts.isConditionalExpression(parent) && child !== parent.condition
142
+ && comparesNull(state, parent.condition, node, child === parent.whenTrue) && !writesReference(state, parent, node)) return true;
143
+ }
144
+ return false;
145
+ }
146
+
147
+ /** Strict-null guards narrow only an immutable reference at its actual guarded use site. */
148
+ export function excludesNull(state, node) {
149
+ const { ts } = state;
150
+ if (!immutableReference(state, node) || priorReferenceWrite(state, node)) return false;
151
+ if (conditionalExcludesNull(state, node)) return true;
152
+ if (!ts.isIdentifier(node)) return false;
153
+ let statement = node;
154
+ while (statement.parent && !ts.isBlock(statement.parent)) {
155
+ if (ts.isFunctionLike(statement.parent) || ts.isClassLike(statement.parent)) return false;
156
+ statement = statement.parent;
157
+ }
158
+ if (!statement.parent || !ts.isBlock(statement.parent)) return false;
159
+ const previous = statement.parent.statements.slice(0, statement.parent.statements.indexOf(statement));
160
+ return previous.some(guard => ts.isIfStatement(guard) && !guard.elseStatement
161
+ && comparesNull(state, guard.expression, node) && returnsAbruptly(ts, guard.thenStatement));
162
+ }
@@ -2,17 +2,20 @@
2
2
  import { nameText } from '../../lib/ts-ast.mjs';
3
3
  import { unwrapCatalogExpression as unwrap } from '../../lib/ts-analysis.mjs';
4
4
  import { callable, staticStrings } from './unused-symbol-values.mjs';
5
- import { returnedExpressions, symbolAt, valuesOf } from './unused-symbol-flow.mjs';
5
+ import { receiverBinding, returnedExpressions, symbolAt, valuesOf, writesBinding } from './unused-symbol-flow.mjs';
6
6
 
7
7
  const TRANSLATOR_NAME = /^(?:t|translate)$/u;
8
8
 
9
- function translationFactories(ts, source) {
10
- const factories = new Set(['useTranslations', 'getTranslations']);
9
+ function translationFactories(state, source) {
10
+ const { ts, checker } = state;
11
+ const factories = new Map(['useTranslations', 'getTranslations'].map(name => [name, {}]));
11
12
  for (const statement of source.statements) {
12
- if (!ts.isImportDeclaration(statement) || !/^(?:next-intl|next-intl\/server)$/u.test(statement.moduleSpecifier.text)) continue;
13
+ if (!ts.isImportDeclaration(statement) || !/^(?:next-intl(?:\/server)?|use-intl\/core)$/u.test(statement.moduleSpecifier.text)) continue;
13
14
  const named = statement.importClause?.namedBindings;
14
15
  if (named && ts.isNamedImports(named)) for (const element of named.elements) {
15
- if (factories.has((element.propertyName ?? element.name).text)) factories.add(element.name.text);
16
+ const name = (element.propertyName ?? element.name).text;
17
+ if (/^(?:useTranslations|getTranslations|createTranslator)$/u.test(name))
18
+ factories.set(element.name.text, { options: name === 'createTranslator', symbol: checker.getSymbolAtLocation(element.name) });
16
19
  }
17
20
  }
18
21
  return factories;
@@ -23,8 +26,14 @@ function translatorNamespace(state, initializer, factories) {
23
26
  let init = unwrap(ts, initializer);
24
27
  if (init && ts.isAwaitExpression(init)) init = unwrap(ts, init.expression);
25
28
  if (!init || !ts.isCallExpression(init) || !ts.isIdentifier(init.expression) || !factories.has(init.expression.text)) return null;
29
+ const factory = factories.get(init.expression.text);
30
+ if (factory.symbol && state.checker.getSymbolAtLocation(init.expression) !== factory.symbol) return null;
26
31
  let first = init.arguments[0];
27
- if (first && ts.isObjectLiteralExpression(first)) first = first.properties.find(property => nameText(ts, property.name, { numbers: true }) === 'namespace')?.initializer;
32
+ if (factory.options && (!first || !ts.isObjectLiteralExpression(first))) return [];
33
+ if (first && ts.isObjectLiteralExpression(first)) {
34
+ if (first.properties.some(property => ts.isSpreadAssignment(property) || nameText(ts, property.name, { numbers: true }) === null)) return [];
35
+ first = first.properties.findLast(property => nameText(ts, property.name, { numbers: true }) === 'namespace')?.initializer;
36
+ }
28
37
  return first ? staticStrings(state, first) : [''];
29
38
  }
30
39
 
@@ -36,7 +45,23 @@ function translatorList(ts, initializer) {
36
45
  return list && ts.isArrayLiteralExpression(list) ? list.elements : [];
37
46
  }
38
47
 
39
- const isFunction = (ts, node) => Boolean(node) && (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node));
48
+ const isFunction = (ts, node) => ts.isFunctionLike(node)
49
+ && (ts.isExpression(node) || ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node));
50
+
51
+ /** A branch may forward the same key or return it unchanged; an unrelated result proves no translator. */
52
+ function forwardedExpression(state, expression, parameter) {
53
+ const { ts, checker } = state;
54
+ const node = unwrap(ts, expression);
55
+ if (node && ts.isIdentifier(node) && checker.getSymbolAtLocation(node) === parameter) return [];
56
+ if (node && ts.isConditionalExpression(node)) {
57
+ const choices = [node.whenTrue, node.whenFalse].map(branch => forwardedExpression(state, branch, parameter));
58
+ return choices.every(choice => choice !== null) ? choices.flat() : null;
59
+ }
60
+ const first = node && ts.isCallExpression(node) ? unwrap(ts, node.arguments[0]) : null;
61
+ if (!first || !ts.isIdentifier(first) || checker.getSymbolAtLocation(first) !== parameter) return null;
62
+ const namespaces = receiverNamespaces(state, node.expression);
63
+ return namespaces.length ? namespaces : null;
64
+ }
40
65
 
41
66
  /** A function whose result is a call on a bound translator with its own first parameter is that translator. */
42
67
  function forwardedNamespaces(state, fn) {
@@ -44,12 +69,9 @@ function forwardedNamespaces(state, fn) {
44
69
  const parameter = fn.parameters[0];
45
70
  if (!parameter || !ts.isIdentifier(parameter.name)) return [];
46
71
  const own = checker.getSymbolAtLocation(parameter.name);
47
- return returnedExpressions(ts, fn).flatMap(expression => {
48
- const call = unwrap(ts, expression);
49
- const first = call && ts.isCallExpression(call) ? unwrap(ts, call.arguments[0]) : null;
50
- if (!first || !ts.isIdentifier(first) || checker.getSymbolAtLocation(first) !== own) return [];
51
- return receiverNamespaces(state, call.expression);
52
- });
72
+ if (writesBinding(state, fn.body, own)) return [];
73
+ const choices = returnedExpressions(ts, fn).map(expression => forwardedExpression(state, expression, own));
74
+ return choices.every(choice => choice !== null) ? choices.flat() : [];
53
75
  }
54
76
 
55
77
  /** useCallback(fn, deps) and useMemo(() => fn, deps) are the function fn. */
@@ -96,7 +118,7 @@ function bindTranslator(state, name, initializer, factories) {
96
118
 
97
119
  /** Wrappers depend on translators bound in other modules, so bind until nothing new appears. */
98
120
  export function translationBindings(state, sources) {
99
- const factories = new Map([...sources.values()].map(source => [source, translationFactories(state.ts, source)]));
121
+ const factories = new Map([...sources.values()].map(source => [source, translationFactories(state, source)]));
100
122
  for (let pass = 0, size = -1; pass < 4 && size !== state.translators.size; pass++) {
101
123
  size = state.translators.size;
102
124
  for (const source of sources.values()) {
@@ -112,6 +134,7 @@ export function translationBindings(state, sources) {
112
134
  /** Namespaces of the translators a receiver can be: declared here, or handed in through arguments and props. */
113
135
  function receiverNamespaces(state, receiver) {
114
136
  const { ts, checker } = state;
137
+ if (explicitCatalogGet(ts, receiver)) return [''];
115
138
  const resolving = state.resolvingReceivers ??= new Set();
116
139
  if (resolving.has(receiver)) return [];
117
140
  resolving.add(receiver);
@@ -120,26 +143,25 @@ function receiverNamespaces(state, receiver) {
120
143
  if (direct !== undefined) return direct;
121
144
  return valuesOf(state, receiver).flatMap(leaf => {
122
145
  // A translator factory called in place as an argument: f(useTranslations("admin")).
123
- if (ts.isCallExpression(leaf)) return translatorNamespace(state, leaf, translationFactories(ts, leaf.getSourceFile())) ?? [];
146
+ if (ts.isCallExpression(leaf)) return translatorNamespace(state, leaf, translationFactories(state, leaf.getSourceFile())) ?? [];
124
147
  if (isFunction(ts, leaf)) return forwardedNamespaces(state, leaf);
125
148
  return ts.isIdentifier(leaf) ? state.translators.get(symbolAt(state, leaf)) ?? [] : [];
126
149
  });
127
150
  } finally { resolving.delete(receiver); }
128
151
  }
129
152
 
130
- function handedIn(state, receiver) {
131
- const { ts } = state;
132
- let root = receiver;
133
- while (ts.isPropertyAccessExpression(root)) root = root.expression;
134
- const declaration = ts.isIdentifier(root) ? symbolAt(state, root)?.valueDeclaration : null;
135
- return Boolean(declaration) && (ts.isParameter(declaration) || ts.isBindingElement(declaration));
136
- }
137
-
138
153
  function leafName(ts, node) {
139
154
  if (ts.isPropertyAccessExpression(node)) return node.name.text;
140
155
  return ts.isIdentifier(node) ? node.text : '';
141
156
  }
142
157
 
158
+ function explicitCatalogGet(ts, callee) {
159
+ if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== 'get') return false;
160
+ const owner = callee.expression;
161
+ const name = nameText(ts, ts.isPropertyAccessExpression(owner) ? owner.name : owner, { numbers: true }) ?? '';
162
+ return /message|catalog/iu.test(name);
163
+ }
164
+
143
165
  /** A callback typed (string, ...) => string: the shape of a translator whatever its name. */
144
166
  function translatorShaped(state, receiver) {
145
167
  const { checker } = state;
@@ -160,12 +182,10 @@ export function catalogNamespace(state, callee) {
160
182
  if (ts.isPropertyAccessExpression(callee) && /^(?:rich|raw|markup)$/u.test(callee.name.text)) receiver = callee.expression;
161
183
  const resolved = receiverNamespaces(state, receiver);
162
184
  if (resolved.length) return resolved;
163
- if (handedIn(state, receiver) && (TRANSLATOR_NAME.test(leafName(ts, receiver)) || translatorShaped(state, receiver))) return [null];
164
- if (ts.isPropertyAccessExpression(callee) && callee.name.text === 'get') {
165
- const owner = callee.expression;
166
- const name = nameText(ts, ts.isPropertyAccessExpression(owner) ? owner.name : owner, { numbers: true }) ?? '';
167
- if (/message|catalog/iu.test(name)) return [''];
168
- }
169
- if (ts.isIdentifier(callee) && TRANSLATOR_NAME.test(callee.text)) return [''];
185
+ const binding = receiverBinding(state, receiver);
186
+ // Observed unresolved producers cannot use the generic namespace fallback.
187
+ if (binding.handedIn && (TRANSLATOR_NAME.test(leafName(ts, receiver)) || translatorShaped(state, receiver)))
188
+ return binding.observed ? [] : [null];
189
+ if (ts.isIdentifier(callee) && TRANSLATOR_NAME.test(callee.text)) return binding.observed ? [] : [''];
170
190
  return [];
171
191
  }
@@ -1,14 +1,13 @@
1
1
  // Static values used at runtime by catalog consumers. A declaration alone is never a read.
2
2
  import path from 'node:path';
3
+ import { nameText } from '../../lib/ts-ast.mjs';
3
4
  import { unwrapCatalogExpression as unwrap } from '../../lib/ts-analysis.mjs';
4
- import { symbolAt, valuesOf } from './unused-symbol-flow.mjs';
5
+ import { callableValue, propertyInitializers, symbolAt, valuesOf } from './unused-symbol-flow.mjs';
5
6
 
6
7
  const JSON_CATALOG = /(?:^|\/)(?:messages|locales)\/[^/]+\.json$|\.catalog\.json$/u;
7
8
 
8
9
  export function callable(state, node) {
9
- const declaration = symbolAt(state, node)?.valueDeclaration;
10
- if (declaration && state.ts.isVariableDeclaration(declaration)) return unwrap(state.ts, declaration.initializer);
11
- return declaration;
10
+ return callableValue(state, node);
12
11
  }
13
12
 
14
13
  function bindArgument(state, parameter, argument) {
@@ -19,6 +18,7 @@ function bindArgument(state, parameter, argument) {
19
18
 
20
19
  /** Record the actual arguments of local functions, including imported functions and arrow exports. */
21
20
  export function argumentBindings(state, sources) {
21
+ state.flowSources = sources;
22
22
  for (const source of sources.values()) {
23
23
  const visit = node => {
24
24
  if (state.ts.isCallExpression(node)) {
@@ -40,22 +40,38 @@ function typeStrings(state, node) {
40
40
  return types.length && types.every(part => part.isStringLiteral()) ? types.map(part => part.value) : [];
41
41
  }
42
42
 
43
+ const STATIC_CHOICE_LIMIT = 256;
44
+ const STATIC_DEPTH_LIMIT = 64;
45
+ const stringChoices = (groups, strict) => strict && groups.some(group => !group.length) ? [] : groups.flat();
46
+
47
+ /** Expand only a finite product; an unknown span or an excessive product keeps the template opaque. */
48
+ function templateStrings(state, node, seen) {
49
+ let choices = [node.head.text];
50
+ for (const span of node.templateSpans) {
51
+ const values = staticStrings(state, span.expression, seen, true);
52
+ if (!values.length || choices.length * values.length > STATIC_CHOICE_LIMIT) return [];
53
+ choices = choices.flatMap(prefix => values.map(value => prefix + value + span.literal.text));
54
+ }
55
+ return choices;
56
+ }
57
+
43
58
  /** Follow finite literals only; a broad string or an unbounded dynamic expression gives no evidence. */
44
- export function staticStrings(state, expression, visited = new Set()) {
59
+ export function staticStrings(state, expression, visited = new Set(), strict = false) {
45
60
  const { ts } = state;
46
61
  const node = unwrap(ts, expression);
47
- if (!node || visited.has(node)) return [];
62
+ if (!node || visited.has(node) || visited.size >= STATIC_DEPTH_LIMIT) return [];
48
63
  const seen = new Set([...visited, node]);
49
64
  if (ts.isStringLiteralLike(node)) return [node.text];
50
- if (ts.isConditionalExpression(node)) return [...staticStrings(state, node.whenTrue, seen), ...staticStrings(state, node.whenFalse, seen)];
65
+ if (ts.isTemplateExpression(node)) return templateStrings(state, node, seen);
66
+ if (ts.isConditionalExpression(node)) return stringChoices([node.whenTrue, node.whenFalse].map(branch => staticStrings(state, branch, seen, strict)), strict);
51
67
  const typed = typeStrings(state, node);
52
68
  if (typed.length) return typed;
53
69
  const declaration = symbolAt(state, node)?.valueDeclaration;
54
- if (declaration && ts.isVariableDeclaration(declaration) && declaration.initializer) return staticStrings(state, declaration.initializer, seen);
70
+ if (declaration && ts.isVariableDeclaration(declaration) && declaration.initializer) return staticStrings(state, declaration.initializer, seen, strict);
55
71
  const bound = declaration && ts.isParameter(declaration) ? state.arguments.get(symbolAt(state, node)) ?? [] : [];
56
- if (bound.length) return bound.flatMap(argument => staticStrings(state, argument, seen));
72
+ if (bound.length) return stringChoices(bound.map(argument => staticStrings(state, argument, seen, strict)), strict);
57
73
  // Tables, tuples, nested maps, for-of and destructured elements resolve to the literals they hold.
58
- return valuesOf(state, node, visited).flatMap(leaf => leaf === node ? [] : staticStrings(state, leaf, seen));
74
+ return stringChoices(valuesOf(state, node, visited).map(leaf => leaf === node ? [] : staticStrings(state, leaf, seen, strict)), strict);
59
75
  }
60
76
 
61
77
  /** JSON imports are catalog owners; their runtime member reads survive named aliases and re-exports. */
@@ -92,8 +108,55 @@ function catalogObjectPath(state, expression, seen = new Set()) {
92
108
  return declaration && ts.isVariableDeclaration(declaration) ? catalogObjectPath(state, declaration.initializer, seen) : null;
93
109
  }
94
110
 
111
+ function initializersAt(state, root, keys) {
112
+ let owners = [root];
113
+ for (const key of keys) {
114
+ const groups = owners.map(owner => propertyInitializers(state, owner, key));
115
+ if (groups.some(group => !group.length)) return [];
116
+ owners = groups.flat();
117
+ }
118
+ return owners;
119
+ }
120
+
121
+ /** Locale siblings belong to the same authored bundle, never to unrelated catalogs with matching keys. */
122
+ function localeInitializers(state, initializer) {
123
+ const { ts } = state;
124
+ const keys = [];
125
+ let property = initializer.parent;
126
+ while (property && ts.isPropertyAssignment(property) && ts.isObjectLiteralExpression(property.parent)) {
127
+ const key = nameText(ts, property.name, { numbers: true });
128
+ if (key === null) return [];
129
+ keys.unshift(key);
130
+ const locale = property.parent.parent;
131
+ if (ts.isPropertyAssignment(locale) && /^(?:vi|en)$/u.test(nameText(ts, locale.name) ?? '')
132
+ && ts.isObjectLiteralExpression(locale.parent) && ts.isVariableDeclaration(locale.parent.parent)) {
133
+ const groups = ['vi', 'en'].map(language => initializersAt(state, locale.parent, [language, ...keys]));
134
+ return groups.every(group => group.length) ? groups.flat() : [];
135
+ }
136
+ property = locale;
137
+ }
138
+ return [];
139
+ }
140
+
141
+ function consumeCandidate(state, initializer) {
142
+ const candidate = state.decls.get(initializer.parent);
143
+ if (candidate?.kind !== 'catalog key') return;
144
+ candidate.used = true;
145
+ for (const peer of localeInitializers(state, initializer)) {
146
+ const sibling = state.decls.get(peer.parent);
147
+ if (sibling?.kind === 'catalog key') sibling.used = true;
148
+ }
149
+ }
150
+
151
+ /** A real finite member read consumes the selected authored key and its proven same-bundle locale siblings. */
152
+ function typedCatalogRead(state, node) {
153
+ const keys = state.ts.isPropertyAccessExpression(node) ? [node.name.text] : staticStrings(state, node.argumentExpression);
154
+ for (const key of keys) for (const initializer of propertyInitializers(state, node.expression, key)) consumeCandidate(state, initializer);
155
+ }
156
+
95
157
  export function jsonCatalogRead(state, node) {
96
158
  if (!state.ts.isPropertyAccessExpression(node) && !state.ts.isElementAccessExpression(node)) return;
159
+ typedCatalogRead(state, node);
97
160
  const key = catalogObjectPath(state, node);
98
161
  if (key !== null) state.catalogReads.add(key.slice(0, -1));
99
162
  if (state.ts.isElementAccessExpression(node) && !state.ts.isStringLiteralLike(node.argumentExpression)) {