fix(tools): follow weight variables in both readings of a keyframe (#1850)

* fix(tools): follow weight variables in both readings of a keyframe

A keyframe that sets a font only while it runs makes scanWeights scan
the file twice: once with the keyframe over the rule that runs it, once
with the rule after it. The wrapper merged only the findings and the
deferred weights of the second pass, so a weight read through a variable
(`font-weight: $w`, `var(--w)`, `font: $w ...`) that meets JetBrains
Mono only after a non-holding animation was never followed to its
definition. Merge the references too; findIndirectWeights already
dedupes a reference both passes record. Definitions, call sites, loads
and declaration counts do not depend on the reading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(tools): cover a keyframe run by another module's mixin

Since #1795 the second reading also starts when an included mixin from
another module runs a keyframe that does not hold its frame. Its
references went through the same wrapper and were dropped the same way;
cover that path too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tools): report a weight definition once however it is reached

findIndirectWeights follows each Sass reference from its own read site,
so a variable two rules read was reported twice at its definition. Both
readings of a non-holding keyframe now reach it too, with different caps,
so a computed weight (`$w: calc(400 + 100)`) got the same warning twice.
Deduplicate its findings by what they report, the key
findWorkspaceWeights already used for landed findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-08 19:44:08 +02:00
1 parent 6c8f5028c7
commit f3c576d182
2 files changed
+94 -5

No files matched your search

+16 -5
View File
@@ -757,6 +757,11 @@ function capOnly(term) {
return Boolean(term.cap) && !offScale;
}
/** What a finding reports (see `describeFinding`): one key, one message. */
function findingKey({ file, line, name, value, cap, computed }) {
return JSON.stringify([file, line, name, value, cap, computed]);
}
/** A string literal in code, whole: its quote and its text. */
const STRING_LITERAL = /^\s*(['"`])([\s\S]*)\1\s*$/;
@@ -940,6 +945,8 @@ export function scanWeights(file, written, modules = {}) {
return {
...running,
findings: union(running.findings, after.findings),
// A variable a weight reads is followed from either reading.
references: union(running.references, after.references),
deferred: union(running.deferred, after.deferred),
// A call meets the families of either reading.
includeCalls: new Map(
@@ -2362,7 +2369,13 @@ export function findIndirectWeights(scans) {
);
}
}
return findings;
// A definition several references reach (two rules reading one Sass
// variable, or both readings of a keyframe) is reported once.
return [
...new Map(
findings.map((finding) => [findingKey(finding), finding])
).values(),
];
}
/**
@@ -2448,13 +2461,11 @@ export function findWorkspaceWeights(sources) {
...scans.flatMap((scan) => scan.findings),
...findIndirectWeights(scans),
];
const keyOf = ({ file, line, name, value, cap, computed }) =>
JSON.stringify([file, line, name, value, cap, computed]);
const seen = new Set(all.filter((f) => !f.landed).map(keyOf));
const seen = new Set(all.filter((f) => !f.landed).map(findingKey));
const findings = [];
for (const { landed, ...finding } of all) {
if (landed) {
const key = keyOf(finding);
const key = findingKey(finding);
if (seen.has(key)) continue;
seen.add(key);
}
+78
View File
@@ -197,6 +197,27 @@ test('follows variables a weight refers to, through chains and files', () => {
);
});
test('reports a definition once however many references reach it', () => {
const mono = "'JetBrains Mono'";
for (const [source, expected] of [
['$w: 650; .a { font-weight: $w; } .b { font-weight: $w; }', '650'],
[
'$w: calc(400 + 100); .a { font-weight: $w; } .b { font-weight: $w; }',
'calc(400 + 100)',
],
[
`$w: calc(400 + 100); .a { font-family: ${mono}; font-weight: $w; } .b { font-weight: $w; }`,
'calc(400 + 100)',
],
]) {
assert.deepEqual(
offScale('libs/a.scss', source),
[`1 $w: ${expected}`],
source
);
}
});
test('checks Angular style bindings and literal DOM writes', () => {
const template = [
'<p [style.font-weight]="active() ? 650 : 400">a</p>',
@@ -4939,6 +4960,63 @@ test('reads a keyframe where a rule runs it, over its own', () => {
}
});
test('follows a weight variable in either reading of a keyframe that does not hold', () => {
const mono = "'JetBrains Mono'";
const roboto = '@keyframes k { to { font-family: Roboto; } }';
const report = (body) =>
findOffScaleWeights('libs/s11/g.scss', body).findings.map(
({ name, value, cap }) =>
`${name}: ${value}${cap ? ` cap ${cap}` : ''}`
);
for (const [source, expected] of [
// Mono once it has run: the variable meets the cap where it is set.
[
`$w: 700; ${roboto} .x { font-family: ${mono}; animation: k 1ms; font-weight: $w; }`,
['$w: 700 cap 500'],
],
[
`:root { --w: 700; } ${roboto} .x { font-family: ${mono}; animation: k 1ms; font-weight: var(--w); }`,
['--w: 700 cap 500'],
],
[
`$w: 700; ${roboto} .x { animation: k 1ms; font: $w 12px/1 ${mono}; }`,
['$w: 700 cap 500'],
],
// A computed weight is one finding, whichever reading reaches it.
[
`$w: calc(400 + 100); ${roboto} .x { font-family: ${mono}; animation: k 1ms; font-weight: $w; }`,
['$w: calc(400 + 100)'],
],
// Mono only while it runs.
[
`$w: 700; .x { font-family: Roboto; animation: k 1ms; font-weight: $w; } @keyframes k { to { font-family: ${mono}; } }`,
['$w: 700 cap 500'],
],
// One that holds its frame keeps Roboto after it.
[
`$w: 700; ${roboto} .x { font-family: ${mono}; animation: k 1ms forwards; font-weight: $w; }`,
[],
],
]) {
assert.deepEqual(report(source), expected, source);
}
// And where another module's mixin runs it.
for (const [run, expected] of [
['k 1ms', ['libs/s11/c.scss:2 $w: 700']],
['k 1ms forwards', []],
]) {
const face = `@mixin face { font-family: ${mono}; animation: ${run}; }`;
assert.deepEqual(
workspace({
'libs/s11/_type.scss': `${roboto}\n${face}`,
'libs/s11/c.scss': `@use 'type';\n$w: 700;\n.x { @include type.face; font-weight: $w; }`,
}),
expected,
run
);
}
});
test('reads an `@at-root` rule where Sass writes it out', () => {
const mono = "'JetBrains Mono'";
const media = '@media (min-width: 1px)';