From e79c61e47edd17f0dad599591f37f96a0d0fc38d Mon Sep 17 00:00:00 2001 From: codecaaron Date: Fri, 7 Aug 2026 01:43:00 -0400 Subject: [PATCH 1/9] feat(extract): coded diagnostics + strict escalation for ancestor-subject selectors (ANI-027 part 1) Ancestor-prefixed subject keys ('[x] &') previously fell through selector recognition and vanished with zero diagnostics; registered aliases with the same shape emitted dead '.C[x] &' rules. Extraction now records a per-property skip carrying the stable code animus.selector.unsupported-subject (error severity), selector-alias values are validated at the system boundary, and one policy point in manifest-diagnostics escalates error-severity diagnostics to build failures under strict while non-strict consumers warn. CssDiagnostic gains optional code/severity fields (absent fields serialize byte-identically). OpenSpec change: ani-015-root-issues (increment 01). Co-Authored-By: Claude Fable 5 --- .../crates/extract-v2/src/analyze_css.rs | 86 ++++++++++++ .../extract/crates/extract-v2/src/eval.rs | 79 +++++++++++ .../crates/extract-v2/src/forced_usage.rs | 2 + .../extract/pipeline/manifest-diagnostics.ts | 102 ++++++++++++-- packages/extract/pipeline/run-analysis.ts | 12 +- .../tests/manifest-diagnostics.test.ts | 129 ++++++++++++++++++ .../next-plugin/src/extraction-session.ts | 1 + packages/vite-plugin/src/context.ts | 1 + vite.config.ts | 1 + 9 files changed, 401 insertions(+), 12 deletions(-) create mode 100644 packages/extract/tests/manifest-diagnostics.test.ts diff --git a/packages/extract/crates/extract-v2/src/analyze_css.rs b/packages/extract/crates/extract-v2/src/analyze_css.rs index a12ea7be..f2b9777c 100644 --- a/packages/extract/crates/extract-v2/src/analyze_css.rs +++ b/packages/extract/crates/extract-v2/src/analyze_css.rs @@ -236,6 +236,35 @@ pub struct CssDiagnostic { /// every existing diagnostic serializes byte-identically. #[serde(skip_serializing_if = "Option::is_none")] pub token: Option, + /// Stable diagnostic code (`animus..`). Absent fields + /// keep existing diagnostics serializing byte-identically. + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + /// `"error"` diagnostics fail strict builds at the plugin policy point; + /// `"warn"` and absent never do. + #[serde(skip_serializing_if = "Option::is_none")] + pub severity: Option, +} + +/// Extract a trailing `(animus..)` marker from a diagnostic message. +pub(crate) fn diagnostic_code_from_message(message: &str) -> Option { + let start = message.rfind("(animus.")?; + let rest = &message[start + 1..]; + let end = rest.find(')')?; + let code = &rest[..end]; + code.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') + .then(|| code.to_string()) +} + +/// Severity assignment for coded diagnostics: unrepresentable-selector codes +/// are error-severity (strict builds fail); everything else stays warn. +pub(crate) fn diagnostic_severity_for_code(code: &str) -> &'static str { + if code == crate::eval::SELECTOR_UNSUPPORTED_SUBJECT { + "error" + } else { + "warn" + } } pub struct CssOutput { @@ -561,6 +590,8 @@ fn shed_unresolved_alias_decls( spans.join(", "), d.property ), + code: None, + severity: None, }); false }); @@ -670,6 +701,8 @@ fn warn_token_shaped_value( will be ignored by browsers.", decl.value, decl.property ), + code: None, + severity: None, }); } @@ -827,6 +860,8 @@ fn record_external_candidates_in_decls( "'{}' in '{}' did not resolve against the consumer theme", token, d.property ), + code: None, + severity: None, }); } } @@ -946,6 +981,8 @@ fn emit_eval_drop_bail( "chain dropped: stage '{}' evaluation failed — {}", stage, detail ), + code: None, + severity: None, }); } @@ -1001,6 +1038,8 @@ fn emit_compose_slot_bail( component in this file or through its imports — composed variant CSS dropped", slot_name, binding ), + code: None, + severity: None, }); } @@ -1488,6 +1527,8 @@ fn run_with_system_floor( component: format!("createTransform('{}')", t.name), kind: "warn".to_string(), message: format!("Failed to register transform in evaluator: {}", err), + code: None, + severity: None, }); } } @@ -1508,6 +1549,8 @@ fn run_with_system_floor( component: format!("createTransform('{}')", t.name), kind: "bail".to_string(), message: diag.clone(), + code: None, + severity: None, }); } } @@ -1539,6 +1582,8 @@ fn run_with_system_floor( component: d.binding.clone(), kind: "bail".to_string(), message: reason.clone(), + code: None, + severity: None, }); } continue; @@ -1566,6 +1611,8 @@ fn run_with_system_floor( component: d.binding.clone(), kind: "bail".to_string(), message: reason, + code: None, + severity: None, }); unresolvable_extensions.insert(component_id); } @@ -1643,12 +1690,18 @@ fn run_with_system_floor( let active_group_names = out.active_group_names; let custom_configs = out.custom_prop_configs; for warning in &out.skip_warnings { + let code = diagnostic_code_from_message(warning); + let severity = code + .as_deref() + .map(|c| diagnostic_severity_for_code(c).to_string()); diagnostics.push(CssDiagnostic { token: None, file: file_path.to_string(), component: chain.descriptor.binding.clone(), kind: "skip".to_string(), message: warning.clone(), + code, + severity, }); } @@ -4191,6 +4244,39 @@ mod tests { ); } + #[test] + fn ancestor_subject_selector_surfaces_coded_error_diagnostic() { + // ANI-027: `'[x] &'` keys previously fell through selector-block + // recognition into resolve_single_prop and vanished with zero + // diagnostics. The guard now records a coded, error-severity skip + // and the rule stays out of the CSS (no dead `.C[x] &` emission). + let out = analyze( + &[( + "a.tsx", + "export const Mark = ds.styles({ '[aria-sort=\"ascending\"] &': { color: 'red' }, color: 'blue' }).asElement('span');\nexport const App = () => ;\n", + )], + &test_inputs(), + ); + let skips = diagnostics_of(&out, "skip"); + assert_eq!(skips.len(), 1, "{:?}", out.diagnostics); + assert_eq!(skips[0].component, "Mark"); + assert_eq!( + skips[0].code.as_deref(), + Some(crate::eval::SELECTOR_UNSUPPORTED_SUBJECT) + ); + assert_eq!(skips[0].severity.as_deref(), Some("error")); + assert!( + skips[0].message.contains("aria-sort"), + "{}", + skips[0].message + ); + // The component still extracts; the ancestor rule is absent and no + // literal `&` leaks into the produced CSS. + assert!(out.css.contains("color:blue") || out.css.contains("color: blue"), "{}", out.css); + assert!(!out.css.contains("aria-sort"), "{}", out.css); + assert!(!out.css.contains('&'), "{}", out.css); + } + #[test] fn extension_child_inherits_parent_base_across_files() { let out = analyze( diff --git a/packages/extract/crates/extract-v2/src/eval.rs b/packages/extract/crates/extract-v2/src/eval.rs index ea36daca..7e187ac0 100644 --- a/packages/extract/crates/extract-v2/src/eval.rs +++ b/packages/extract/crates/extract-v2/src/eval.rs @@ -37,6 +37,26 @@ pub struct SkippedProperty { pub reason: String, } +/// Stable diagnostic code for ancestor-subject selector keys (ANI-027). +pub const SELECTOR_UNSUPPORTED_SUBJECT: &str = "animus.selector.unsupported-subject"; + +/// True when a style key places `&` after an ancestor prefix — the first `&` +/// occurrence is not at byte 0 (`'[aria-sort="ascending"] &'`, +/// `'.group:hover &:hover'`). Leading-subject keys (`'&:hover'`, `'& + &'`) +/// are not flagged; multi-subject semantics stay with the leading-`&` path. +pub(crate) fn ancestor_subject_key(key: &str) -> bool { + matches!(key.find('&'), Some(pos) if pos > 0) +} + +fn ancestor_subject_skip(key: &str) -> SkippedProperty { + SkippedProperty { + key: key.to_string(), + reason: format!( + "selector '{key}' places '&' after an ancestor prefix ({SELECTOR_UNSUPPORTED_SUBJECT})" + ), + } +} + /// A function expression captured from a `transform` field instead of being evaluated. /// The span references the source text of the function body. #[derive(Debug, Clone)] @@ -83,6 +103,14 @@ pub fn eval_object_expr_with_statics( let key = eval_property_key(&prop.key)?; + // Ancestor-subject selector keys are unrepresentable in the + // stored suffix form: record a coded skip instead of letting + // theme resolution drop the rule silently (ANI-027). + if ancestor_subject_key(&key) { + skipped.push(ancestor_subject_skip(&key)); + continue; + } + // Special case: capture function expressions on `transform` fields if key == "transform" { match &prop.value { @@ -1275,4 +1303,55 @@ const Component = { gap: GAP };"#; } panic!("failed to parse test object"); } + + #[test] + fn ancestor_subject_key_predicate() { + assert!(ancestor_subject_key(r#"[aria-sort="ascending"] &"#)); + assert!(ancestor_subject_key(r#"[aria-sort="descending"] &:hover"#)); + assert!(ancestor_subject_key(".group:hover &")); + assert!(!ancestor_subject_key("&:hover")); + assert!(!ancestor_subject_key("& + &")); + assert!(!ancestor_subject_key("& .icon")); + assert!(!ancestor_subject_key("color")); + assert!(!ancestor_subject_key("_hover")); + } + + #[test] + fn ancestor_subject_key_records_coded_skip_and_omits_property() { + let (val, skips) = parse_obj_full( + r#"{ '[aria-sort="ascending"] &': { color: 'red' }, color: 'blue' }"#, + ); + assert_eq!(skips.len(), 1, "{:?}", skips); + assert!( + skips[0].reason.contains(SELECTOR_UNSUPPORTED_SUBJECT), + "{}", + skips[0].reason + ); + assert!(skips[0].key.contains("aria-sort")); + let obj = val.as_object().unwrap(); + assert!(!obj.contains_key(r#"[aria-sort="ascending"] &"#)); + assert_eq!(obj.get("color"), Some(&Value::String("blue".into()))); + } + + #[test] + fn ancestor_subject_key_caught_in_nested_objects() { + let (val, skips) = + parse_obj_full(r#"{ '&:hover': { '.parent &': { color: 'red' } } }"#); + assert_eq!(skips.len(), 1, "{:?}", skips); + assert!(skips[0].reason.contains(SELECTOR_UNSUPPORTED_SUBJECT)); + let hover = val.as_object().unwrap().get("&:hover").unwrap(); + assert!(hover.as_object().unwrap().is_empty()); + } + + #[test] + fn leading_subject_keys_unaffected_by_ancestor_guard() { + let (val, skips) = parse_obj_full( + r#"{ '&:hover': { color: 'red' }, '& + &': { gap: 4 }, '& .icon': { opacity: 1 } }"#, + ); + assert!(skips.is_empty(), "{:?}", skips); + let obj = val.as_object().unwrap(); + assert!(obj.contains_key("&:hover")); + assert!(obj.contains_key("& + &")); + assert!(obj.contains_key("& .icon")); + } } diff --git a/packages/extract/crates/extract-v2/src/forced_usage.rs b/packages/extract/crates/extract-v2/src/forced_usage.rs index 0f014628..9ad2771e 100644 --- a/packages/extract/crates/extract-v2/src/forced_usage.rs +++ b/packages/extract/crates/extract-v2/src/forced_usage.rs @@ -126,6 +126,8 @@ fn warn(warnings: &mut Vec, component: &str, message: String) { component: component.to_string(), kind: "warn".to_string(), message, + code: None, + severity: None, }); } diff --git a/packages/extract/pipeline/manifest-diagnostics.ts b/packages/extract/pipeline/manifest-diagnostics.ts index d54ccd31..b450a288 100644 --- a/packages/extract/pipeline/manifest-diagnostics.ts +++ b/packages/extract/pipeline/manifest-diagnostics.ts @@ -6,28 +6,112 @@ export type ManifestDiagnostic = { /** Structured token path (`scale.key`) — present only on * `external-token-candidate` diagnostics (cross-source correlation). */ token?: string; + /** Stable diagnostic code (`animus..`). */ + code?: string; + /** `"error"` fails strict builds at this policy point; `"warn"`/absent + * never does. */ + severity?: string; }; +/** Stable code for ancestor-subject selector forms (ANI-027). Mirrors the + * Rust constant in `extract-v2/src/eval.rs`. */ +export const SELECTOR_UNSUPPORTED_SUBJECT = + 'animus.selector.unsupported-subject'; + +export interface DiagnosticPolicy { + /** When true, error-severity diagnostics throw instead of warning. */ + strict?: boolean; + /** System-level diagnostics (e.g. selector-alias validation) surfaced + * ahead of the manifest's own, through the same policy. */ + prepend?: ManifestDiagnostic[]; +} + +/** True when a selector string places its first `&` after an ancestor + * prefix — the form extraction cannot represent (leading-`&` is fine). */ +export function ancestorSubjectSelector(value: string): boolean { + const pos = value.indexOf('&'); + return pos > 0; +} + +/** + * Synthesize coded diagnostics for registered selector-alias values whose + * `&` sits after an ancestor prefix. These never reach the Rust evaluator + * (aliases are recognized by their `_name` key, then emitted dead), so the + * system-config boundary is where they must fail loud. + */ +export function collectSelectorAliasDiagnostics( + selectorAliasesJson: string | null | undefined +): ManifestDiagnostic[] { + if (!selectorAliasesJson) return []; + let aliases: Record; + try { + aliases = JSON.parse(selectorAliasesJson); + } catch { + return []; + } + const diagnostics: ManifestDiagnostic[] = []; + for (const [name, value] of Object.entries(aliases)) { + if (typeof value !== 'string') continue; + for (const branch of value.split(',')) { + if (ancestorSubjectSelector(branch.trim())) { + diagnostics.push({ + file: 'system', + component: name, + kind: 'warn', + message: `selector alias '${name}' value '${value}' places '&' after an ancestor prefix (${SELECTOR_UNSUPPORTED_SUBJECT})`, + code: SELECTOR_UNSUPPORTED_SUBJECT, + severity: 'error', + }); + break; + } + } + } + return diagnostics; +} + /** * Surface extraction-manifest diagnostics through a plugin's warn channel. * - * Single authoritative copy for both extraction plugins. Surfaces `bail` - * (component not extracted), `skip` (component skipped), and `warn` kinds; - * unknown kinds stay silent. + * Single authoritative copy for both extraction plugins — and the single + * strict-escalation policy point: error-severity diagnostics throw one + * Error naming every offender when `policy.strict`, and print as warnings + * otherwise. Surfaces `bail` (component not extracted), `skip` (component + * skipped), and `warn` kinds; unknown kinds stay silent. Printed lines + * include the diagnostic code when the message doesn't already carry it. */ export function surfaceManifestDiagnostics( manifest: { diagnostics?: ManifestDiagnostic[] }, - warn: (message: string) => void + warn: (message: string) => void, + policy: DiagnosticPolicy = {} ): void { - for (const diagnostic of manifest.diagnostics ?? []) { + const errors: string[] = []; + const diagnostics = policy.prepend?.length + ? [...policy.prepend, ...(manifest.diagnostics ?? [])] + : (manifest.diagnostics ?? []); + for (const diagnostic of diagnostics) { + let line: string | null = null; if (diagnostic.kind === 'bail') { - warn(`⚠ ${diagnostic.component} not extracted: ${diagnostic.message}`); + line = `⚠ ${diagnostic.component} not extracted: ${diagnostic.message}`; } else if (diagnostic.kind === 'skip') { - warn(`⚠ ${diagnostic.component}: skipped ${diagnostic.message}`); + line = `⚠ ${diagnostic.component}: skipped ${diagnostic.message}`; } else if (diagnostic.kind === 'warn') { - warn( - `⚠ ${diagnostic.file}: ${diagnostic.component}: ${diagnostic.message}` + line = `⚠ ${diagnostic.file}: ${diagnostic.component}: ${diagnostic.message}`; + } + if (line === null) continue; + if (diagnostic.code && !diagnostic.message.includes(diagnostic.code)) { + line += ` [${diagnostic.code}]`; + } + if (policy.strict && diagnostic.severity === 'error') { + errors.push( + `${diagnostic.code ?? 'error'} — ${diagnostic.component}: ${diagnostic.message}` ); + continue; } + warn(line); + } + if (errors.length > 0) { + throw new Error( + `[animus] strict: ${errors.length} error diagnostic(s):\n${errors.join('\n')}` + ); } } diff --git a/packages/extract/pipeline/run-analysis.ts b/packages/extract/pipeline/run-analysis.ts index 4e138663..ecd42c0a 100644 --- a/packages/extract/pipeline/run-analysis.ts +++ b/packages/extract/pipeline/run-analysis.ts @@ -1,5 +1,8 @@ import { buildAnalyzeProjectArgs } from './analyze-project-args'; -import { surfaceManifestDiagnostics } from './manifest-diagnostics'; +import { + collectSelectorAliasDiagnostics, + surfaceManifestDiagnostics, +} from './manifest-diagnostics'; import { applyUnitFallback } from './unit-fallback'; import type { AnalyzeProjectInputs } from './analyze-project-args'; @@ -103,7 +106,7 @@ function hasSourceThemeManifests(system: SystemConfig): boolean { export function runProjectAnalysis( // eslint-disable-next-line @typescript-eslint/no-explicit-any engineApi: () => any, - opts: AnalysisOptions & { warn: (message: string) => void } + opts: AnalysisOptions & { warn: (message: string) => void; strict?: boolean } ): ProjectAnalysisResult { const { analyzeProject } = engineApi(); @@ -119,7 +122,10 @@ export function runProjectAnalysis( t = performance.now(); const manifest = JSON.parse(manifestJson); - surfaceManifestDiagnostics(manifest, opts.warn); + surfaceManifestDiagnostics(manifest, opts.warn, { + strict: opts.strict, + prepend: collectSelectorAliasDiagnostics(opts.system.selectorAliasesJson), + }); const parseMs = Math.round(performance.now() - t); return { diff --git a/packages/extract/tests/manifest-diagnostics.test.ts b/packages/extract/tests/manifest-diagnostics.test.ts new file mode 100644 index 00000000..e731a6ae --- /dev/null +++ b/packages/extract/tests/manifest-diagnostics.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { + SELECTOR_UNSUPPORTED_SUBJECT, + ancestorSubjectSelector, + collectSelectorAliasDiagnostics, + surfaceManifestDiagnostics, +} from '../pipeline/manifest-diagnostics'; + +import type { ManifestDiagnostic } from '../pipeline/manifest-diagnostics'; + +const errorDiagnostic: ManifestDiagnostic = { + file: 'a.tsx', + component: 'Mark', + kind: 'skip', + message: `selector '[aria-sort="ascending"] &' places '&' after an ancestor prefix (${SELECTOR_UNSUPPORTED_SUBJECT})`, + code: SELECTOR_UNSUPPORTED_SUBJECT, + severity: 'error', +}; + +describe('ancestorSubjectSelector', () => { + it('flags ancestor-prefixed subjects only', () => { + expect(ancestorSubjectSelector('[aria-sort="ascending"] &')).toBe(true); + expect(ancestorSubjectSelector('.group:hover &:hover')).toBe(true); + expect(ancestorSubjectSelector('&:hover')).toBe(false); + expect(ancestorSubjectSelector('& + &')).toBe(false); + expect(ancestorSubjectSelector(':hover')).toBe(false); + }); +}); + +describe('surfaceManifestDiagnostics strict policy', () => { + it('throws under strict with every error diagnostic named', () => { + const warned: string[] = []; + expect(() => + surfaceManifestDiagnostics( + { diagnostics: [errorDiagnostic] }, + (m) => warned.push(m), + { strict: true } + ) + ).toThrow(new RegExp(SELECTOR_UNSUPPORTED_SUBJECT.replace(/\./g, '\\.'))); + expect(warned).toHaveLength(0); + }); + + it('warns and proceeds without strict', () => { + const warned: string[] = []; + surfaceManifestDiagnostics({ diagnostics: [errorDiagnostic] }, (m) => + warned.push(m) + ); + expect(warned).toHaveLength(1); + expect(warned[0]).toContain(SELECTOR_UNSUPPORTED_SUBJECT); + }); + + it('never escalates warn-severity skips under strict', () => { + const warned: string[] = []; + surfaceManifestDiagnostics( + { + diagnostics: [ + { + file: 'a.tsx', + component: 'Button', + kind: 'skip', + message: "property 'gap' — variable reference (non-static)", + }, + ], + }, + (m) => warned.push(m), + { strict: true } + ); + expect(warned).toHaveLength(1); + }); + + it('appends the code to printed lines only when the message lacks it', () => { + const warned: string[] = []; + surfaceManifestDiagnostics( + { + diagnostics: [ + { ...errorDiagnostic, message: 'ancestor prefix unsupported' }, + ], + }, + (m) => warned.push(m) + ); + expect(warned[0]).toContain(`[${SELECTOR_UNSUPPORTED_SUBJECT}]`); + const alreadyCoded: string[] = []; + surfaceManifestDiagnostics({ diagnostics: [errorDiagnostic] }, (m) => + alreadyCoded.push(m) + ); + expect(alreadyCoded[0].endsWith(`[${SELECTOR_UNSUPPORTED_SUBJECT}]`)).toBe( + false + ); + }); +}); + +describe('collectSelectorAliasDiagnostics', () => { + it('flags ancestor-subject alias values with the coded error', () => { + const diagnostics = collectSelectorAliasDiagnostics( + JSON.stringify({ + _hover: '&:hover', + _groupHover: '.group:hover &', + _dark: '[data-color-mode="dark"] &', + }) + ); + expect(diagnostics.map((d) => d.component).sort()).toEqual([ + '_dark', + '_groupHover', + ]); + for (const diagnostic of diagnostics) { + expect(diagnostic.code).toBe(SELECTOR_UNSUPPORTED_SUBJECT); + expect(diagnostic.severity).toBe('error'); + } + }); + + it('accepts leading-subject aliases and empty registries', () => { + expect( + collectSelectorAliasDiagnostics( + JSON.stringify({ _hover: '&:hover, &[data-hover]' }) + ) + ).toEqual([]); + expect(collectSelectorAliasDiagnostics(null)).toEqual([]); + expect(collectSelectorAliasDiagnostics('not-json')).toEqual([]); + }); + + it('flags a comma list whose second branch is ancestor-subject', () => { + const diagnostics = collectSelectorAliasDiagnostics( + JSON.stringify({ _mixed: '&:focus-visible, .group:hover &' }) + ); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0].component).toBe('_mixed'); + }); +}); diff --git a/packages/next-plugin/src/extraction-session.ts b/packages/next-plugin/src/extraction-session.ts index cac77259..dcb848bc 100644 --- a/packages/next-plugin/src/extraction-session.ts +++ b/packages/next-plugin/src/extraction-session.ts @@ -693,6 +693,7 @@ export class ExtractionSession { const result = runProjectAnalysis(engineApi, { ...analysisOptions, warn: (message) => this.warn(message), + strict: this.options.strict, }); // Cross-source token contracts (extraction-diagnostics): engine diff --git a/packages/vite-plugin/src/context.ts b/packages/vite-plugin/src/context.ts index fe8f0b8b..fd656667 100644 --- a/packages/vite-plugin/src/context.ts +++ b/packages/vite-plugin/src/context.ts @@ -463,6 +463,7 @@ export class PluginContext { ), devMode: !this.isProd, warn: (m) => this.warn(m), + strict: this.options.strict, }); this.storedManifest = result.manifest; diff --git a/vite.config.ts b/vite.config.ts index d415ae63..ca9f8091 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,7 @@ const typescriptTestTargets = [ 'packages/extract/tests/correlate-external-tokens.test.ts', 'packages/extract/tests/discover-packages.test.ts', 'packages/extract/tests/dynamic-prop-config.test.ts', + 'packages/extract/tests/manifest-diagnostics.test.ts', 'packages/extract/tests/path-aliases.test.ts', 'packages/extract/tests/post-process-css.test.ts', 'packages/extract/tests/resolve-asset.test.ts', From 5d42852005436db92b47896ee6446065298995e8 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Fri, 7 Aug 2026 01:50:26 -0400 Subject: [PATCH 2/9] feat(extract): statics-aware variant/compound stages + type-assertion transparency (ANI-020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deliberate v1-parity departure (register-tracked): parse_variant_arg and the compound second arg now resolve identifier-backed maps, base styles, and option objects through the same extraction-time statics as .styles() arguments — same-file or imported across package boundaries via engine Pass A. TS type wrappers (as const, satisfies, non-null, parens) are transparent at every evaluation surface: const collection, expression eval, object/identifier span indexing, and chain-argument capture, so `.styles(x as const)` extracts like `.styles(x)` instead of dropping the chain. Inline-vs-binding byte-equality pinned at unit and engine level; genuinely dynamic maps keep their skip witness. Parity oracle byte-stable (register stayed empty). OpenSpec change: ani-015-root-issues (increment 02). Co-Authored-By: Claude Fable 5 --- .../crates/extract-v2/src/analyze_css.rs | 89 +++++++++++--- .../crates/extract-v2/src/chain_walk.rs | 1 + .../crates/extract-v2/src/chain_walk/expr.rs | 8 +- .../extract-v2/src/chain_walk/terminal.rs | 32 ++++- .../extract/crates/extract-v2/src/engine.rs | 33 ++++++ .../extract/crates/extract-v2/src/eval.rs | 110 +++++++++++++++++- .../extract/crates/extract-v2/src/facts.rs | 56 +++++++-- 7 files changed, 289 insertions(+), 40 deletions(-) diff --git a/packages/extract/crates/extract-v2/src/analyze_css.rs b/packages/extract/crates/extract-v2/src/analyze_css.rs index f2b9777c..6e89481c 100644 --- a/packages/extract/crates/extract-v2/src/analyze_css.rs +++ b/packages/extract/crates/extract-v2/src/analyze_css.rs @@ -4201,32 +4201,92 @@ mod tests { } #[test] - fn identifier_variant_map_surfaces_a_skip_diagnostic() { - // `variants: ` produced options:[] with a surviving - // default and ZERO diagnostics — an emitted class carrying no CSS and - // no witness (per-property-bail spec: every skipped property SHALL - // warn). Outcomes are unchanged; what is now guaranteed is that a - // variant map the parser could not READ — a non-object value, or a - // spread inside the object — always leaves a skip behind. An absent - // or genuinely empty `variants` is not a loss and records nothing. + fn identifier_variant_map_resolves_through_statics() { + // ani-015 D3 departure (semantic-const-resolution, variant stage): + // `variants: ` bound to a top-level const resolves through + // the same extraction-time statics as `.styles()` arguments — the + // manifest is identical to inlining the literal, with zero skips. + // (v1 was statics-blind here: options:[] + a surviving default.) let out = analyze( &[( "a.tsx", - "const sizes = { sm: { p: 8 } };\nexport const Button = ds.styles({ display: 'flex' }).variant({ prop: 'size', defaultVariant: 'sm', variants: sizes }).asElement('button');\nexport const App = () =>