From 79d4beef0add4d0a4f0f3ef055f846c71c99ed51 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Mon, 3 Aug 2026 20:06:04 -0400 Subject: [PATCH 1/8] ANI-015: unwrap inline type assertions on terminal targets; bail on unresolvable asComponent asComponent(Link as React.ComponentType) and asElement('div' as const) now extract like their bare forms (TS as/satisfies/non-null/paren wrappers are peeled). A target with no static identifier name bails the chain to the runtime path with a named reason instead of emitting createComponent(unknown, ...) - a guaranteed ReferenceError in-browser. Verified: clippy, hygiene:rust, unit:rust (4 new chain_walk tests), canary, parity, integration all green. Co-Authored-By: Claude Fable 5 --- .../crates/extract-v2/src/chain_walk.rs | 143 ++++++++++++++++-- 1 file changed, 127 insertions(+), 16 deletions(-) diff --git a/packages/extract/crates/extract-v2/src/chain_walk.rs b/packages/extract/crates/extract-v2/src/chain_walk.rs index 4e30f773..fc4bfd23 100644 --- a/packages/extract/crates/extract-v2/src/chain_walk.rs +++ b/packages/extract/crates/extract-v2/src/chain_walk.rs @@ -99,11 +99,18 @@ fn try_walk_chain(call: &CallExpression<'_>, binding: String) -> Option return None, }; - let tag = extract_terminal_arg(call, &terminal).unwrap_or_default(); - let mut stages = Vec::new(); let mut extractable = true; let mut bail_reason: Option = None; + + let tag = match extract_terminal_arg(call, &terminal) { + TerminalArg::Resolved(tag) => tag, + TerminalArg::Unresolvable(reason) => { + extractable = false; + bail_reason = Some(format!("{}: {}", method_name, reason)); + String::new() + } + }; let mut has_extend_marker = false; let chain_end = call.span; @@ -205,21 +212,57 @@ fn match_static_member<'a, 'b>(expr: &'a Expression<'b>) -> Option<(&'a Expressi } } -fn extract_terminal_arg(call: &CallExpression<'_>, terminal: &TerminalKind) -> Option { +/// What the terminal argument resolved to: a static name the emitter may +/// compile into the replacement, or a bail. Emitting a placeholder for an +/// unresolvable target is never an option — `createComponent(unknown, …)` +/// is a ReferenceError in the browser (ANI-015). +enum TerminalArg { + Resolved(String), + Unresolvable(String), +} + +/// Peel TS type-assertion wrappers and parentheses from a terminal argument: +/// `asComponent(Link as ComponentType)` names the same runtime value as +/// `asComponent(Link)`, and `asElement('div' as const)` the same tag as +/// `asElement('div')`. +fn unwrap_type_assertions<'a, 'b>(expr: &'a Expression<'b>) -> &'a Expression<'b> { + match expr { + Expression::TSAsExpression(x) => unwrap_type_assertions(&x.expression), + Expression::TSSatisfiesExpression(x) => unwrap_type_assertions(&x.expression), + Expression::TSNonNullExpression(x) => unwrap_type_assertions(&x.expression), + Expression::ParenthesizedExpression(x) => unwrap_type_assertions(&x.expression), + _ => expr, + } +} + +fn extract_terminal_arg(call: &CallExpression<'_>, terminal: &TerminalKind) -> TerminalArg { match terminal { - TerminalKind::AsClass => Some(String::new()), - _ => { - let first_arg = call.arguments.first()?; - match terminal { - TerminalKind::AsElement => match first_arg { - Argument::StringLiteral(lit) => Some(lit.value.to_string()), - _ => None, - }, - TerminalKind::AsComponent => match first_arg { - Argument::Identifier(id) => Some(id.name.to_string()), - _ => Some("unknown".to_string()), - }, - TerminalKind::AsClass => unreachable!(), + TerminalKind::AsClass => TerminalArg::Resolved(String::new()), + TerminalKind::AsElement => { + // v1 parity: a missing or non-literal tag keeps the empty tag. + match call + .arguments + .first() + .and_then(|arg| arg.as_expression()) + .map(unwrap_type_assertions) + { + Some(Expression::StringLiteral(lit)) => { + TerminalArg::Resolved(lit.value.to_string()) + } + _ => TerminalArg::Resolved(String::new()), + } + } + TerminalKind::AsComponent => { + match call + .arguments + .first() + .and_then(|arg| arg.as_expression()) + .map(unwrap_type_assertions) + { + Some(Expression::Identifier(id)) => TerminalArg::Resolved(id.name.to_string()), + _ => TerminalArg::Unresolvable( + "target has no static identifier name".to_string(), + ), } } } @@ -354,6 +397,74 @@ mod tests { assert_eq!(chains[0].extends_from, None); } + // ── ANI-015: inline-asserted terminal targets ───────────────────────────── + + #[test] + fn extracts_as_component_with_inline_as_assertion() { + let chains = parse_chains( + r#" + import { animus } from '@animus-ui/core'; + const FlowLink = animus + .styles({ fontWeight: 400 }) + .asComponent(Link as React.ComponentType); + "#, + ); + assert_eq!(chains.len(), 1); + assert!(chains[0].extractable); + assert_eq!(chains[0].tag, "Link"); + } + + #[test] + fn extracts_as_component_with_satisfies_assertion() { + let chains = parse_chains( + r#" + import { animus } from '@animus-ui/core'; + const FlowLink = animus + .styles({ fontWeight: 400 }) + .asComponent(Link satisfies LinkLike); + "#, + ); + assert_eq!(chains.len(), 1); + assert!(chains[0].extractable); + assert_eq!(chains[0].tag, "Link"); + } + + #[test] + fn extracts_as_element_with_const_assertion() { + let chains = parse_chains( + r#" + import { animus } from '@animus-ui/core'; + const Box = animus.styles({ display: 'flex' }).asElement('div' as const); + "#, + ); + assert_eq!(chains.len(), 1); + assert!(chains[0].extractable); + assert_eq!(chains[0].tag, "div"); + } + + #[test] + fn bails_on_unresolvable_as_component_target() { + // A computed target has no static name to emit; the chain must bail + // to the runtime path, never emit a placeholder identifier + // (`createComponent(unknown, …)` is a ReferenceError in the browser). + let chains = parse_chains( + r#" + import { animus } from '@animus-ui/core'; + const FlowLink = animus + .styles({ fontWeight: 400 }) + .asComponent(withRouter(Link)); + "#, + ); + assert_eq!(chains.len(), 1); + assert!(!chains[0].extractable); + assert!(chains[0] + .bail_reason + .as_deref() + .unwrap_or_default() + .contains("asComponent")); + assert_ne!(chains[0].tag, "unknown"); + } + #[test] fn finds_multiple_chains() { let chains = parse_chains( From 408f02d3bc727629d27ae232f98d538066dc6fdb Mon Sep 17 00:00:00 2001 From: codecaaron Date: Mon, 3 Aug 2026 20:09:01 -0400 Subject: [PATCH 2/8] ANI-006: pin two-hop transitive system-dependency invalidation in the dev lane New scenario re-roots the fixture theme through src/palette.ts (ds.ts -> theme.ts -> palette.ts) and proves an edit to the two-hop module alone triggers the geological reset and lands in the variable CSS. Passing on current main - the gap was verification, not behavior. Co-Authored-By: Claude Fable 5 --- .../tests/dev-lane/dev-server.test.ts | 46 +++++++++++++++++++ .../vite-plugin/tests/dev-lane/fixture.ts | 28 +++++++++++ 2 files changed, 74 insertions(+) diff --git a/packages/vite-plugin/tests/dev-lane/dev-server.test.ts b/packages/vite-plugin/tests/dev-lane/dev-server.test.ts index aeb5e05e..2d8f962b 100644 --- a/packages/vite-plugin/tests/dev-lane/dev-server.test.ts +++ b/packages/vite-plugin/tests/dev-lane/dev-server.test.ts @@ -29,8 +29,10 @@ import { componentSource, createDevFixture, INITIAL_BRAND_HEX, + paletteSource, systemSource, themeSource, + themeViaPaletteSource, } from './fixture'; import { probeDevLanePrerequisites } from './prerequisites'; import { @@ -327,6 +329,50 @@ suite( expect(after.componentCss).toContain(buttonClass); }); + it('a two-hop transitive dependency joins the reset set after a reload', async () => { + // ANI-006: broader transitive system-registry invalidation. The loader + // reports every module it evaluated; membership must extend to a + // dependency introduced two hops from the entry (ds.ts → theme.ts → + // palette.ts), not just to files the entry imports directly. + const TRANSITIVE_HEX = '#123456'; + const before = await adapter.read(); + + // Introduce the second hop at the current (repaired) hex. The theme + // edit is already a member, so this write resets and re-reports the + // dependency graph — which now includes palette.ts. + fixture.write('src/palette.ts', paletteSource(REPAIRED_BRAND_HEX)); + fixture.write('src/theme.ts', themeViaPaletteSource()); + await until( + async () => { + const served = await adapter.read(); + return served.staticRevision > before.staticRevision ? served : false; + }, + { + what: 'a fresh static revision after re-rooting the theme through palette.ts', + describe: async () => + `revision: ${(await adapter.read()).staticRevision}${renderTrace(adapter)}`, + } + ); + + // Now edit ONLY the two-hop module. If membership stopped at the first + // hop this write is treated as a plain component-file event and the + // variable CSS never changes. + fixture.write('src/palette.ts', paletteSource(TRANSITIVE_HEX)); + const after = await until( + async () => { + const served = await adapter.read(); + return served.staticCss.includes(TRANSITIVE_HEX) ? served : false; + }, + { + what: `variable CSS picks up ${TRANSITIVE_HEX} after a two-hop palette edit`, + describe: async () => + `variable CSS:\n${(await adapter.read()).staticCss}${renderTrace(adapter)}`, + } + ); + expect(after.staticCss).not.toContain(REPAIRED_BRAND_HEX); + expect(after.componentCss).toContain(buttonClass); + }); + it('a second cold server serves the same CSS as the incremental one', async () => { const incremental: DevArtifacts = await adapter.read(); diff --git a/packages/vite-plugin/tests/dev-lane/fixture.ts b/packages/vite-plugin/tests/dev-lane/fixture.ts index d8fa9e86..2c70310c 100644 --- a/packages/vite-plugin/tests/dev-lane/fixture.ts +++ b/packages/vite-plugin/tests/dev-lane/fixture.ts @@ -47,6 +47,34 @@ export const tokens = createTheme() `; } +/** A one-export palette module — the second hop for the transitive test. */ +export function paletteSource(brandHex: string): string { + return `export const BRAND_500 = '${brandHex}';\n`; +} + +/** + * A theme that imports its brand hex from `./palette` — two hops from the + * system entry (`ds.ts → theme.ts → palette.ts`). The loader reports every + * evaluated module, so palette.ts must join the geological-reset set. + */ +export function themeViaPaletteSource(): string { + return `import { createTheme } from '@animus-ui/system'; +import { BRAND_500 } from './palette'; + +export const tokens = createTheme() + .addColors({ brand: { 500: BRAND_500 } }) + .addColorModes('light', { + light: { primary: 'brand.500' }, + dark: { primary: 'brand.500' }, + }) + .addScale({ + name: 'space', + values: { 0: '0', 4: '0.25rem', 8: '0.5rem', 16: '1rem' }, + }) + .build(); +`; +} + /** A theme file that cannot be parsed — used by the failure/recovery scenarios. */ export function brokenThemeSource(): string { return `import { createTheme } from '@animus-ui/system'; From 18b31ae785b0cc1415af55f0a7852d6ffc739a60 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Mon, 3 Aug 2026 20:10:52 -0400 Subject: [PATCH 3/8] ANI-010: watch external package dirs so their edits and deletes reach hotUpdate registerSystemWatchPaths only registered loader-reported system deps; externalPackageDirs (workspace DS packages outside the root walk) were never watched, so their file events never fired and the deletion-pruning path - which already handles ..-relative cache keys - was never driven. External dirs now register, including a call after discovery assigns them (both prior registration points run earlier in the lifecycle). node_modules-installed packages remain unwatchable (Vite hard-ignores them), matching the documented system-dependency limitation. Co-Authored-By: Claude Fable 5 --- packages/vite-plugin/src/build-start.ts | 4 ++ packages/vite-plugin/src/context.ts | 15 +++++- .../tests/watch-registration.test.ts | 47 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 packages/vite-plugin/tests/watch-registration.test.ts diff --git a/packages/vite-plugin/src/build-start.ts b/packages/vite-plugin/src/build-start.ts index d844e97e..33c0bd06 100644 --- a/packages/vite-plugin/src/build-start.ts +++ b/packages/vite-plugin/src/build-start.ts @@ -158,6 +158,10 @@ export async function runBuildStart( } ctx.externalPackageDirs = collected.packageDirs; + // Both prior registration points run before this assignment + // (configureServer precedes buildStart; loadSystem precedes discovery), so + // external dirs must register here or they are never watched (ANI-010). + ctx.registerSystemWatchPaths(); const packageFileCount = fileEntries.length - localFileCount; ctx.log( diff --git a/packages/vite-plugin/src/context.ts b/packages/vite-plugin/src/context.ts index ebcbe6f6..ce6800d5 100644 --- a/packages/vite-plugin/src/context.ts +++ b/packages/vite-plugin/src/context.ts @@ -460,8 +460,19 @@ export class PluginContext { */ registerSystemWatchPaths(): void { const watcher = this.devServer?.watcher; - if (!watcher || this.systemDependencyPaths.length === 0) return; - watcher.add(this.systemDependencyPaths); + if (!watcher) return; + if (this.systemDependencyPaths.length > 0) { + watcher.add(this.systemDependencyPaths); + } + // External DS package sources live outside the root walk; without an + // explicit watch their edits and deletions never reach `hotUpdate`, so + // the deletion-pruning path is never driven and the last-extracted CSS + // survives (ANI-010). node_modules-installed packages remain unwatchable + // (Vite hard-ignores them) — the same documented limitation as system + // dependencies above; workspace-resolved dirs are real paths and watch. + if (this.externalPackageDirs.length > 0) { + watcher.add(this.externalPackageDirs); + } } /** diff --git a/packages/vite-plugin/tests/watch-registration.test.ts b/packages/vite-plugin/tests/watch-registration.test.ts new file mode 100644 index 00000000..84f8abc7 --- /dev/null +++ b/packages/vite-plugin/tests/watch-registration.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { PluginContext } from '../src/context'; + +/** + * ANI-010: external DS package sources live outside the root walk, so + * without an explicit `watcher.add` their edits and deletions never reach + * `hotUpdate` — the pruning path exists but no event ever drives it, and the + * last-extracted CSS survives for the life of the dev server. + */ +describe('registerSystemWatchPaths', () => { + function contextWithWatcher() { + const ctx = new PluginContext({ system: './src/ds.ts' }); + const add = vi.fn(); + ctx.devServer = { watcher: { add } }; + return { ctx, add }; + } + + it('registers loader-reported system dependency paths', () => { + const { ctx, add } = contextWithWatcher(); + ctx.systemDependencyPaths = ['/ws/tokens/src/theme.ts']; + ctx.registerSystemWatchPaths(); + expect(add).toHaveBeenCalledWith(['/ws/tokens/src/theme.ts']); + }); + + it('registers external package directories alongside system paths', () => { + const { ctx, add } = contextWithWatcher(); + ctx.systemDependencyPaths = ['/ws/tokens/src/theme.ts']; + ctx.externalPackageDirs = ['/ws/ui-kit/src']; + ctx.registerSystemWatchPaths(); + expect(add).toHaveBeenCalledWith(['/ws/tokens/src/theme.ts']); + expect(add).toHaveBeenCalledWith(['/ws/ui-kit/src']); + }); + + it('registers external package directories even with no system paths', () => { + const { ctx, add } = contextWithWatcher(); + ctx.externalPackageDirs = ['/ws/ui-kit/src']; + ctx.registerSystemWatchPaths(); + expect(add).toHaveBeenCalledWith(['/ws/ui-kit/src']); + }); + + it('no-ops without a dev server', () => { + const ctx = new PluginContext({ system: './src/ds.ts' }); + ctx.externalPackageDirs = ['/ws/ui-kit/src']; + expect(() => ctx.registerSystemWatchPaths()).not.toThrow(); + }); +}); From 39f8cd46c19afed7b174562623c08f4c47f4e411 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Mon, 3 Aug 2026 22:43:06 -0400 Subject: [PATCH 4/8] ani-ledger-closeout inc-01: strict mode fails on unresolvable includes external-package-file-discovery delta: silence is never an outcome. The shared pipeline gains unresolvableIncludesMessage(); vite buildStart gates through ctx.enforceIncludeResolution() (strict throws naming every offending specifier, non-strict warns), runSelfVerify reports unresolvable alongside empty, and next-plugin applies the same gate in its session. Verified: build:ts, verify:compile, verify:unit:ts, vite-plugin dependents claim, next-app owner claim - all green. Co-Authored-By: Claude Fable 5 --- .../extract/pipeline/discover-packages.ts | 16 ++++++ packages/extract/pipeline/index.ts | 1 + .../tests/collect-external-packages.test.ts | 23 +++++++- .../next-plugin/src/extraction-session.ts | 11 ++++ packages/vite-plugin/src/build-start.ts | 1 + packages/vite-plugin/src/context.ts | 25 ++++++++- .../tests/self-verify-includes.test.ts | 55 +++++++++++++++++-- 7 files changed, 123 insertions(+), 9 deletions(-) diff --git a/packages/extract/pipeline/discover-packages.ts b/packages/extract/pipeline/discover-packages.ts index 82301e36..f46fc57e 100644 --- a/packages/extract/pipeline/discover-packages.ts +++ b/packages/extract/pipeline/discover-packages.ts @@ -273,6 +273,22 @@ export async function collectExternalPackageSources(opts: { * * Falls back to empty array if no `includes` declaration is found. */ +/** + * The message for the strict/warn gate over unresolvable includes, or null + * when every declared specifier resolved (external-package-file-discovery: + * silence is never an outcome — non-strict consumers warn with this line, + * strict consumers throw it). + */ +export function unresolvableIncludesMessage( + outcomes: ExternalPackageOutcome[] +): string | null { + const unresolvable = outcomes + .filter((record) => record.outcome === 'unresolvable') + .map((record) => record.specifier); + if (unresolvable.length === 0) return null; + return `[animus-extract] unresolvable include specifier(s): ${unresolvable.join(', ')}`; +} + export function extractSystemFilePackages(systemFilePath: string): string[] { let source: string; try { diff --git a/packages/extract/pipeline/index.ts b/packages/extract/pipeline/index.ts index 083d4774..fcc08ad5 100644 --- a/packages/extract/pipeline/index.ts +++ b/packages/extract/pipeline/index.ts @@ -35,6 +35,7 @@ export { collectExternalPackageSources, extractSystemFilePackages, findPackageRoot, + unresolvableIncludesMessage, } from './discover-packages'; export { buildPathAliasesJson } from './path-aliases'; export type { LightningTargets } from './post-process-css'; diff --git a/packages/extract/tests/collect-external-packages.test.ts b/packages/extract/tests/collect-external-packages.test.ts index 23e7dc29..b8ae0dc3 100644 --- a/packages/extract/tests/collect-external-packages.test.ts +++ b/packages/extract/tests/collect-external-packages.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from 'os'; import { join, relative } from 'path'; import { afterEach, describe, expect, test } from 'vitest'; -import { collectExternalPackageSources } from '../pipeline/discover-packages'; +import { + collectExternalPackageSources, + unresolvableIncludesMessage, +} from '../pipeline/discover-packages'; const tempRoots: string[] = []; @@ -240,6 +243,24 @@ describe('collectExternalPackageSources', () => { ]); }); + test('unresolvableIncludesMessage names every unresolvable specifier, null when all resolve', () => { + expect( + unresolvableIncludesMessage([ + { specifier: '@x/missing', outcome: 'unresolvable', fileCount: 0 }, + { specifier: '@x/ds', outcome: 'resolved', fileCount: 2 }, + { specifier: '@x/typo', outcome: 'unresolvable', fileCount: 0 }, + ]) + ).toBe( + '[animus-extract] unresolvable include specifier(s): @x/missing, @x/typo' + ); + expect( + unresolvableIncludesMessage([ + { specifier: '@x/ds', outcome: 'resolved', fileCount: 2 }, + { specifier: '@x/empty', outcome: 'empty', fileCount: 0 }, + ]) + ).toBeNull(); + }); + test('records an empty outcome when a resolved package contributes no sources', async () => { const root = makeRoot(); const pkg = makePackage(join(root, 'packages', 'ds'), { diff --git a/packages/next-plugin/src/extraction-session.ts b/packages/next-plugin/src/extraction-session.ts index 4ca1277d..ed668fc8 100644 --- a/packages/next-plugin/src/extraction-session.ts +++ b/packages/next-plugin/src/extraction-session.ts @@ -14,6 +14,7 @@ import { runProjectAnalysis, serializeStaticCss, toWatchKeys, + unresolvableIncludesMessage, } from '@animus-ui/extract/pipeline'; import { existsSync, @@ -487,6 +488,16 @@ export class ExtractionSession { ); } } + // external-package-file-discovery: silence is never an outcome — an + // unresolvable include warns in non-strict mode and fails the build + // under strict, naming every offending specifier (vite-plugin parity). + const unresolvableMessage = unresolvableIncludesMessage(collected.outcomes); + if (unresolvableMessage !== null) { + if (this.options.strict) { + throw new Error(unresolvableMessage); + } + this.warn(unresolvableMessage); + } const packageMap = collected.packageMap; this.lastPackageMap = packageMap; diff --git a/packages/vite-plugin/src/build-start.ts b/packages/vite-plugin/src/build-start.ts index 33c0bd06..e98f2814 100644 --- a/packages/vite-plugin/src/build-start.ts +++ b/packages/vite-plugin/src/build-start.ts @@ -146,6 +146,7 @@ export async function runBuildStart( ctx.packageMap = collected.packageMap; ctx.externalPackageOutcomes = collected.outcomes; + ctx.enforceIncludeResolution(); for (const [specifier, srcEntry] of collected.sourceEntries) { ctx.externalSourceEntries.set(specifier, srcEntry); } diff --git a/packages/vite-plugin/src/context.ts b/packages/vite-plugin/src/context.ts index ce6800d5..be045d85 100644 --- a/packages/vite-plugin/src/context.ts +++ b/packages/vite-plugin/src/context.ts @@ -8,6 +8,7 @@ import { runProjectAnalysis, serializeStaticCss, toWatchKeys, + unresolvableIncludesMessage, } from '@animus-ui/extract/pipeline'; import { relative, resolve } from 'path'; @@ -508,6 +509,22 @@ export class PluginContext { }, 100); } + /** + * The buildStart gate over include resolution + * (external-package-file-discovery: silence is never an outcome). An + * unresolvable `.includes()` specifier warns in non-strict mode and FAILS + * the build under `strict: true`, naming every offending specifier — + * a typo'd include must not ship a build missing its component CSS. + */ + enforceIncludeResolution(): void { + const message = unresolvableIncludesMessage(this.externalPackageOutcomes); + if (message === null) return; + if (this.options.strict) { + throw new Error(message); + } + this.warn(message); + } + runSelfVerify(): void { const failures: string[] = []; @@ -518,14 +535,16 @@ export class PluginContext { } // A declared include that resolved but yielded nothing is a silent - // misconfiguration (empty src/, everything filtered out). An UNRESOLVABLE - // specifier is deliberately not flagged — silent skip is spec-mandated - // (external-package-file-discovery). + // misconfiguration (empty src/, everything filtered out), and an + // UNRESOLVABLE specifier is a typo'd or missing package — both surface + // (external-package-file-discovery: silence is never an outcome). for (const { specifier, outcome } of this.externalPackageOutcomes) { if (outcome === 'empty') { failures.push( `include '${specifier}' resolved but discovered no component sources` ); + } else if (outcome === 'unresolvable') { + failures.push(`include '${specifier}' could not be resolved`); } } diff --git a/packages/vite-plugin/tests/self-verify-includes.test.ts b/packages/vite-plugin/tests/self-verify-includes.test.ts index 5d64d48a..496b6461 100644 --- a/packages/vite-plugin/tests/self-verify-includes.test.ts +++ b/packages/vite-plugin/tests/self-verify-includes.test.ts @@ -3,10 +3,11 @@ import { describe, expect, test } from 'vitest'; import { PluginContext } from '../src/context'; /** - * The self-verify gate over external-package discovery outcomes: an include + * The gates over external-package discovery outcomes + * (external-package-file-discovery: silence is never an outcome): an include * that resolved but yielded no sources is a silent misconfiguration and must - * surface; an UNRESOLVABLE include stays silent (spec-mandated skip in - * external-package-file-discovery). + * surface, and an UNRESOLVABLE include warns in non-strict mode and FAILS + * the build under strict (ani-ledger-closeout). */ /** A context whose other self-verify checks all pass. */ @@ -29,14 +30,16 @@ describe('self-verify: external package include outcomes', () => { ); }); - test('an unresolvable include is not flagged', () => { + test('an unresolvable include fails verification', () => { const ctx = makeContext(true); ctx.externalPackageOutcomes = [ { specifier: '@x/missing', outcome: 'unresolvable', fileCount: 0 }, { specifier: '@x/ds', outcome: 'resolved', fileCount: 3 }, ]; - expect(() => ctx.runSelfVerify()).not.toThrow(); + expect(() => ctx.runSelfVerify()).toThrow( + "[animus:verify] include '@x/missing' could not be resolved" + ); }); test('non-strict mode warns instead of throwing', () => { @@ -57,3 +60,45 @@ describe('self-verify: external package include outcomes', () => { ]); }); }); + +describe('buildStart gate: enforceIncludeResolution', () => { + test('strict mode throws naming every unresolvable specifier', () => { + const ctx = makeContext(true); + ctx.externalPackageOutcomes = [ + { specifier: '@x/missing', outcome: 'unresolvable', fileCount: 0 }, + { specifier: '@x/typo', outcome: 'unresolvable', fileCount: 0 }, + { specifier: '@x/ds', outcome: 'resolved', fileCount: 3 }, + ]; + + expect(() => ctx.enforceIncludeResolution()).toThrow( + '[animus-extract] unresolvable include specifier(s): @x/missing, @x/typo' + ); + }); + + test('non-strict mode warns and continues', () => { + const ctx = makeContext(false); + const warnings: string[] = []; + ctx.logger = { + warn: (message: string) => warnings.push(message), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + ctx.externalPackageOutcomes = [ + { specifier: '@x/missing', outcome: 'unresolvable', fileCount: 0 }, + ]; + + expect(() => ctx.enforceIncludeResolution()).not.toThrow(); + expect(warnings).toEqual([ + '[animus] [animus-extract] unresolvable include specifier(s): @x/missing', + ]); + }); + + test('no unresolvable outcomes is a no-op in both modes', () => { + const ctx = makeContext(true); + ctx.externalPackageOutcomes = [ + { specifier: '@x/ds', outcome: 'resolved', fileCount: 3 }, + { specifier: '@x/empty', outcome: 'empty', fileCount: 0 }, + ]; + + expect(() => ctx.enforceIncludeResolution()).not.toThrow(); + }); +}); From 5c8f2805adaec011aa0f6d79e44f0a56b3110800 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Mon, 3 Aug 2026 22:49:13 -0400 Subject: [PATCH 5/8] ani-ledger-closeout inc-02: typed @font-face resources on createGlobalStyles global-styles-system delta (ANI-013). FontFace/FontFaceSrc types; the factory takes an optional { fontFaces } second argument and carries the descriptors on the block (field omitted when empty - legacy blocks stay byte-identical). The loader serializes the wrapped { styles, fontFaces } form; resolve_all_global_blocks accepts wrapped and legacy shapes and renders font-face blocks AHEAD of selector rules under @layer anm-global. src urls emit byte-exact (host bundler owns asset resolution); family resolves through the token vocabulary. Verified: NAPI + TS builds, compile, types, unit:ts, unit:rust, clippy, canary, integration - all green. Co-Authored-By: Claude Fable 5 --- .../extract/crates/extract-v2/src/theme.rs | 166 +++++++++++++++++- .../extract/crates/system-loader/src/lib.rs | 8 +- .../global-styles-font-faces.test.ts | 57 ++++++ packages/system/__tests__/types.test-d.tsx | 26 +++ packages/system/src/SystemBuilder.ts | 45 ++++- packages/system/src/index.ts | 2 + 6 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 packages/system/__tests__/global-styles-font-faces.test.ts diff --git a/packages/extract/crates/extract-v2/src/theme.rs b/packages/extract/crates/extract-v2/src/theme.rs index 1386aa09..99b4a1dc 100644 --- a/packages/extract/crates/extract-v2/src/theme.rs +++ b/packages/extract/crates/extract-v2/src/theme.rs @@ -1394,7 +1394,25 @@ pub fn resolve_all_global_blocks( let mut parts: Vec = Vec::new(); for (_name, block) in block_map { - let css = resolve_global_block(block, ctx); + // Wrapped loader form: { styles, fontFaces } — typed font faces + // render AHEAD of the block's selector rules (global-styles-system). + // A legacy bare selector map passes through unchanged. + let (styles, faces) = match block.as_object() { + Some(obj) + if obj.get("styles").map(|s| s.is_object()).unwrap_or(false) + && obj.keys().all(|k| k == "styles" || k == "fontFaces") => + { + (obj.get("styles").unwrap(), obj.get("fontFaces")) + } + _ => (block, None), + }; + if let Some(faces) = faces { + let css = render_font_faces(faces, ctx); + if !css.is_empty() { + parts.push(css); + } + } + let css = resolve_global_block(styles, ctx); if !css.is_empty() { parts.push(css); } @@ -1403,6 +1421,76 @@ pub fn resolve_all_global_blocks( parts.join("\n\n") } +/// Render a wrapped block's typed `@font-face` descriptors +/// (global-styles-system). `src` urls are emitted byte-exact as authored — +/// asset resolution belongs to the host bundler's CSS pipeline. `family` +/// resolves through the token vocabulary; every other descriptor is a CSS +/// literal. Descriptors missing `family` or a non-empty `src` are skipped. +fn render_font_faces(faces: &Value, ctx: &ResolveContext) -> String { + let list = match faces.as_array() { + Some(l) => l, + None => return String::new(), + }; + let mut blocks: Vec = Vec::new(); + for face in list { + let obj = match face.as_object() { + Some(o) => o, + None => continue, + }; + let family = match obj.get("family").and_then(|v| v.as_str()) { + Some(f) => f, + None => continue, + }; + let srcs = match obj.get("src").and_then(|v| v.as_array()) { + Some(s) if !s.is_empty() => s, + _ => continue, + }; + let family = resolve_token_aliases( + family, + ctx.theme, + ctx.variable_map, + ctx.contextual_vars, + ); + let mut src_parts: Vec = Vec::new(); + for entry in srcs { + let entry = match entry.as_object() { + Some(e) => e, + None => continue, + }; + let url = match entry.get("url").and_then(|v| v.as_str()) { + Some(u) => u, + None => continue, + }; + match entry.get("format").and_then(|v| v.as_str()) { + Some(fmt) => { + src_parts.push(format!("url('{url}') format('{fmt}')")) + } + None => src_parts.push(format!("url('{url}')")), + } + } + if src_parts.is_empty() { + continue; + } + let mut decls = vec![ + format!("font-family: {family};"), + format!("src: {};", src_parts.join(", ")), + ]; + for (key, css_name) in [ + ("style", "font-style"), + ("weight", "font-weight"), + ("stretch", "font-stretch"), + ("display", "font-display"), + ("unicodeRange", "unicode-range"), + ] { + if let Some(v) = obj.get(key).and_then(|v| v.as_str()) { + decls.push(format!("{css_name}: {v};")); + } + } + blocks.push(format!("@font-face {{ {} }}", decls.join(" "))); + } + blocks.join("\n") +} + /// Resolve a single keyframes block (from the top-level `keyframes()` primitive) /// into `@keyframes { ... }` CSS. The block shape is `{ name, frames }` /// where `frames` is `{ "0%" → { prop → value }, ... }`. Each frame's styles @@ -2186,6 +2274,82 @@ mod tests { assert_eq!(resolved.declarations[0].value, "rgb(1 2 3)"); } + // ── ani-ledger-closeout: typed @font-face resources ────────────────── + + #[test] + fn font_faces_render_ahead_of_selector_rules_in_wrapped_blocks() { + let owner = TestCtxOwner::new(); + let blocks = json!({ + "globals": { + "styles": { "body": { "color": "red" } }, + "fontFaces": [{ + "family": "Inter", + "src": [{ "url": "/fonts/inter.woff2", "format": "woff2" }], + "weight": "100 900", + "display": "swap" + }] + } + }); + let css = resolve_all_global_blocks(&blocks, &owner.ctx()); + let face = css.find("@font-face").expect("font-face rendered"); + let rule = css.find("body").expect("selector rule rendered"); + assert!(face < rule, "font-face must precede selector rules:\n{css}"); + assert!(css.contains( + "@font-face { font-family: Inter; src: url('/fonts/inter.woff2') format('woff2'); font-weight: 100 900; font-display: swap; }" + ), "unexpected font-face rendering:\n{css}"); + } + + #[test] + fn font_face_urls_pass_through_byte_exact() { + let owner = TestCtxOwner::new(); + let blocks = json!({ + "globals": { + "styles": {}, + "fontFaces": [{ + "family": "Inter", + "src": [{ "url": "./assets/inter.woff2" }] + }] + } + }); + let css = resolve_all_global_blocks(&blocks, &owner.ctx()); + assert!(css.contains("src: url('./assets/inter.woff2');")); + } + + #[test] + fn font_face_family_resolves_font_scale_token() { + let mut owner = TestCtxOwner::new(); + owner + .theme + .insert("fonts.body".to_string(), "Inter, sans-serif".to_string()); + let blocks = json!({ + "globals": { + "styles": {}, + "fontFaces": [{ + "family": "{fonts.body}", + "src": [{ "url": "/fonts/inter.woff2" }] + }] + } + }); + let css = resolve_all_global_blocks(&blocks, &owner.ctx()); + assert!( + css.contains("font-family: Inter, sans-serif;"), + "family token unresolved:\n{css}" + ); + } + + #[test] + fn legacy_bare_selector_map_blocks_resolve_unchanged() { + let owner = TestCtxOwner::new(); + let wrapped = json!({ + "globals": { "styles": { "body": { "color": "red" } }, "fontFaces": [] } + }); + let legacy = json!({ "globals": { "body": { "color": "red" } } }); + let wrapped_css = resolve_all_global_blocks(&wrapped, &owner.ctx()); + let legacy_css = resolve_all_global_blocks(&legacy, &owner.ctx()); + assert_eq!(wrapped_css, legacy_css); + assert!(!legacy_css.contains("@font-face")); + } + #[test] fn dotted_literal_on_non_color_prop_stays_untouched() { // `fontFamily` is pass-through but outside the color family — a dotted diff --git a/packages/extract/crates/system-loader/src/lib.rs b/packages/extract/crates/system-loader/src/lib.rs index 96149d39..e3dc2bcb 100644 --- a/packages/extract/crates/system-loader/src/lib.rs +++ b/packages/extract/crates/system-loader/src/lib.rs @@ -1287,8 +1287,12 @@ fn extract_global_style_blocks(namespace: &Object<'_>) -> Option { if let Ok(obj) = namespace.get::<_, Object>(key.as_str()) { if let Ok(brand) = obj.get::<_, String>("__brand") { if brand == "GlobalStyleBlock" { - // Use eval to call JSON.stringify on the styles property - let script = format!("JSON.stringify(globalThis.__ns_ref[\"{}\"].styles)", key); + // Wrapped form: selector map plus the block's typed + // font-face descriptors (global-styles-system). The + // extractor renders fontFaces ahead of selector rules. + let script = format!( + "JSON.stringify({{styles: globalThis.__ns_ref[\"{key}\"].styles, fontFaces: globalThis.__ns_ref[\"{key}\"].fontFaces || []}})" + ); // Temporarily assign namespace to globalThis for access let _ = ctx.globals().set("__ns_ref", namespace.clone()); if let Ok(json_str) = ctx.eval::(script.as_bytes()) { diff --git a/packages/system/__tests__/global-styles-font-faces.test.ts b/packages/system/__tests__/global-styles-font-faces.test.ts new file mode 100644 index 00000000..d9e582fe --- /dev/null +++ b/packages/system/__tests__/global-styles-font-faces.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { createGlobalStyles } from './test-system'; + +/** + * ani-ledger-closeout: typed @font-face resources (global-styles-system). + * The factory carries descriptors on the block; the loader serializes them + * and the extractor renders them ahead of selector rules — those halves are + * pinned in Rust (theme.rs font_face tests). Here: the authoring surface. + */ +describe('createGlobalStyles fontFaces', () => { + it('carries typed descriptors on the block', () => { + const block = createGlobalStyles( + { body: { m: 0 } }, + { + fontFaces: [ + { + family: 'Inter', + src: [{ url: '/fonts/inter.woff2', format: 'woff2' }], + weight: '100 900', + display: 'swap', + }, + ], + } + ); + + expect(block.__brand).toBe('GlobalStyleBlock'); + expect(block.fontFaces).toEqual([ + { + family: 'Inter', + src: [{ url: '/fonts/inter.woff2', format: 'woff2' }], + weight: '100 900', + display: 'swap', + }, + ]); + }); + + it('omits the field entirely without descriptors (byte-identical legacy blocks)', () => { + expect('fontFaces' in createGlobalStyles({ body: { m: 0 } })).toBe(false); + expect( + 'fontFaces' in createGlobalStyles({ body: { m: 0 } }, { fontFaces: [] }) + ).toBe(false); + }); + + it('copies the descriptor array so later caller mutation cannot leak in', () => { + const authored = [ + { family: 'Inter', src: [{ url: '/fonts/inter.woff2' }] }, + ]; + const block = createGlobalStyles( + { body: { m: 0 } }, + { fontFaces: authored } + ); + authored.push({ family: 'Mono', src: [{ url: '/fonts/mono.woff2' }] }); + + expect(block.fontFaces).toHaveLength(1); + }); +}); diff --git a/packages/system/__tests__/types.test-d.tsx b/packages/system/__tests__/types.test-d.tsx index 9468d14b..cf3963bd 100644 --- a/packages/system/__tests__/types.test-d.tsx +++ b/packages/system/__tests__/types.test-d.tsx @@ -1553,6 +1553,32 @@ void createGlobalStyles({ body: { p: 16 }, }); +// Positive: typed font-face descriptors ride the optional second argument +void createGlobalStyles( + { body: { p: 16 } }, + { + fontFaces: [ + { + family: 'Inter', + src: [{ url: '/fonts/inter.woff2', format: 'woff2' }], + weight: '100 900', + display: 'swap', + }, + ], + } +); + +// Negative: a font-face descriptor rejects unknown keys +void createGlobalStyles( + { body: { p: 16 } }, + { + fontFaces: [ + // @ts-expect-error — 'variant' is not a FontFace descriptor + { family: 'Inter', src: [{ url: '/f.woff2' }], variant: 'small-caps' }, + ], + } +); + // Negative: unknown scale key rejected in global style body // @ts-expect-error — 'nonexistent' is not a key of the colors scale void createGlobalStyles({ body: { bg: 'nonexistent' } }); diff --git a/packages/system/src/SystemBuilder.ts b/packages/system/src/SystemBuilder.ts index 21d47ed6..d67137ce 100644 --- a/packages/system/src/SystemBuilder.ts +++ b/packages/system/src/SystemBuilder.ts @@ -32,16 +32,47 @@ interface SerializedPropEntry { export type GlobalStyleMap = Record>; +/** One `src` descriptor of a font-face resource. */ +export interface FontFaceSrc { + /** + * Emitted byte-exact as authored — asset resolution and rewriting belong + * to the host bundler's CSS asset pipeline, not to extraction. + */ + url: string; + /** Format hint (`woff2`, `woff`, …), rendered as `format('…')`. */ + format?: string; +} + +/** + * A typed `@font-face` descriptor (global-styles-system). `family` may use a + * font-scale token reference (`{fonts.body}`); other descriptors take CSS + * literals only. + */ +export interface FontFace { + family: string; + src: FontFaceSrc[]; + weight?: string; + style?: string; + display?: string; + unicodeRange?: string; + stretch?: string; +} + export interface GlobalStyleBlock { __brand: 'GlobalStyleBlock'; styles: GlobalStyleMap; + /** Rendered ahead of the block's selector rules in `@layer anm-global`. */ + fontFaces?: FontFace[]; } export type GlobalStylesFactory< PropReg extends Record = Record, -> = >(styles: { - readonly [K in keyof Map]: ThemedCSSProps; -}) => GlobalStyleBlock; +> = >( + styles: { + readonly [K in keyof Map]: ThemedCSSProps; + }, + options?: { fontFaces?: readonly FontFace[] } +) => GlobalStyleBlock; export type CreateKeyframesFactory< PropReg extends Record = Record, @@ -298,9 +329,15 @@ export class SystemBuilder< }, }) as SystemInstance; - const createGlobalStyles = ((styles: GlobalStyleMap): GlobalStyleBlock => ({ + const createGlobalStyles = (( + styles: GlobalStyleMap, + options?: { fontFaces?: readonly FontFace[] } + ): GlobalStyleBlock => ({ __brand: 'GlobalStyleBlock' as const, styles, + ...(options?.fontFaces?.length + ? { fontFaces: [...options.fontFaces] } + : {}), })) as GlobalStylesFactory; const createKeyframes = ((frames: Record) => diff --git a/packages/system/src/index.ts b/packages/system/src/index.ts index f9eb8798..c253f685 100644 --- a/packages/system/src/index.ts +++ b/packages/system/src/index.ts @@ -15,6 +15,8 @@ export { createClassResolver } from './runtime/createClassResolver'; export { createComposedFamily } from './runtime/createComposedFamily'; export type { CreateKeyframesFactory, + FontFace, + FontFaceSrc, GlobalStyleBlock, GlobalStyleMap, GlobalStylesFactory, From 17e08c780130d0c31371212eaf8c7aaecc349bca Mon Sep 17 00:00:00 2001 From: codecaaron Date: Mon, 3 Aug 2026 22:55:02 -0400 Subject: [PATCH 6/8] ani-ledger-closeout inc-03: parity corpus fixture batch, one baseline refresh Audit found ANI-004/005/008 fixtures already present (compose-slot-bail, duplicate-compose-modules, extension-compounds, compose-default). Added the two gaps: inline-asserted-targets.tsx (ANI-015 - as-const tag and as-typed component targets extract like bare forms) and color-family-pass-through.tsx (ANI-009 - backgroundColor resolves the semantic token, borderTopColor literal passes through, responsive object slot emits the sm media query; the array form was corrected to the pinned object slot form after the first capture showed arrays drop on pass-through props). One privileged refresh under intent ani-closeout-fixture-batch-20260803; register cleared after absorption. Verified: verify:unit:ts, verify:parity green. Co-Authored-By: Claude Fable 5 --- packages/_parity/baseline-intents.md | 10 ++++ .../_parity/baselines/v2/development.json | 47 ++++++++++++++++++- packages/_parity/baselines/v2/production.json | 47 ++++++++++++++++++- .../corpus/color-family-pass-through.tsx | 13 +++++ .../corpus/inline-asserted-targets.tsx | 20 ++++++++ packages/_parity/scoreboard.snap | 4 +- packages/_parity/self-check.snap | 4 +- 7 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 packages/_parity/corpus/color-family-pass-through.tsx create mode 100644 packages/_parity/corpus/inline-asserted-targets.tsx diff --git a/packages/_parity/baseline-intents.md b/packages/_parity/baseline-intents.md index f1b92a82..86c7c2df 100644 --- a/packages/_parity/baseline-intents.md +++ b/packages/_parity/baseline-intents.md @@ -66,3 +66,13 @@ committed production/development pair. Ordinary parity runs never write it. concordance + semantic differential, registering the expected `duplicate-binding` drift when it does. Every pre-existing unit stays byte-identical in the same run. +- [x] `ani-closeout-fixture-batch-20260803` — refresh once after adding the + two audit-gap corpus fixtures for the ledger closeout change + (openspec: ani-ledger-closeout, increment 03): + `inline-asserted-targets.tsx` (ANI-015 — an `as const` tag and an + `as`-typed component target extract exactly like their bare forms + after the chain_walk assertion-unwrap fix) and + `color-family-pass-through.tsx` (ANI-009 — `backgroundColor`/`color` + longhands resolve semantic tokens at top level and in responsive + slots; a `borderTopColor` literal passes through). New units only — + every pre-existing unit stays byte-identical in the same run. diff --git a/packages/_parity/baselines/v2/development.json b/packages/_parity/baselines/v2/development.json index 5ae11cd7..28e854ad 100644 --- a/packages/_parity/baselines/v2/development.json +++ b/packages/_parity/baselines/v2/development.json @@ -1,8 +1,8 @@ { - "corpusSha256": "dcc22235874fcc95d2bc65d0dc00ce11b61100c0a80e46fe14c34a9b9ac7116b", + "corpusSha256": "bce35dce13dd19571bd5ec514c29e0434e0686cdc8c1db9745af0be597f4916f", "engine": "v2", "mode": "development", - "refreshIntent": "ani-fix-witness-fixtures-20260803", + "refreshIntent": "ani-closeout-fixture-batch-20260803", "surfaceSchemaSha256": "43eb1265e97aab8497c11ca05e8a4159a6d4f4b69a6837a704e1d96fa75eb108", "units": { "extract-all": { @@ -748,6 +748,27 @@ }, "parseCount": 1 }, + "parity/color-family-pass-through.tsx": { + "code": { + "color-family-pass-through.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-009 witness: raw CSS color-property names resolve semantic tokens via\n// COLOR_FAMILY_PASS_THROUGH — the DS registers `bg`, not `backgroundColor`,\n// yet the longhand must reach the colors scale at top level and in\n// responsive slots, while non-token values pass through literally.\nexport const PassThrough = createComponent('section', 'animus-PassThrough-a05c8778', {});\n\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-PassThrough-a05c8778 {\n border-top-color: rgb(1 2 3);\n color: var(--color-primary);\n background-color: var(--color-primary);\n }\n @media (min-width: 768px) {\n .animus-PassThrough-a05c8778 {\n color: var(--color-secondary);\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "color-family-pass-through.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "color-family-pass-through.tsx::PassThrough" + ], + "componentFragmentsJson": "{\"color-family-pass-through.tsx::PassThrough\":{\"base\":\" .animus-PassThrough-a05c8778 {\\n border-top-color: rgb(1 2 3);\\n color: var(--color-primary);\\n background-color: var(--color-primary);\\n }\\n @media (min-width: 768px) {\\n .animus-PassThrough-a05c8778 {\\n color: var(--color-secondary);\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-PassThrough-a05c8778 {\\n border-top-color: rgb(1 2 3);\\n color: var(--color-primary);\\n background-color: var(--color-primary);\\n }\\n @media (min-width: 768px) {\\n .animus-PassThrough-a05c8778 {\\n color: var(--color-secondary);\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, "parity/compose-container-card.tsx": { "code": { "compose-container-card.tsx": "import { createComponent, createComposedFamily } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 08) — blessed into the committed oracle.\n// Canonical compose-slot CONTAINER pattern: the Root slot establishes a named\n// query container (container-name/container-type — design D7 pass-through\n// declarations) and slotted children respond via raw `@container card (…)`\n// block keys (design D2, no registration). Mirrors the landed test-ds\n// `ContainerCard` family; here as a parity oracle unit so the compose ×\n// container-establishment × @container-response combination is byte-pinned.\n\nimport { ds } from '../test-system';\n\nconst Root = createComponent('article', 'animus-Root-b4b4101f', {});\n\nconst Media = createComponent('div', 'animus-Media-cdf8bb75', {});\n\nconst Body = createComponent('div', 'animus-Body-c5ef664b', {});\n\nexport const ContainerCard = createComposedFamily({ Root: Root, Media: Media, Body: Body }, { name: \"ContainerCard\" });\nexport const App = () => (\n \n \n \n \n);\n\n" @@ -1222,6 +1243,28 @@ }, "parseCount": 2 }, + "parity/inline-asserted-targets.tsx": { + "code": { + "inline-asserted-targets.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-015 witness: inline type assertions on terminal targets extract\n// exactly like their bare forms — the walker unwraps as/satisfies/non-null\n// wrappers and the emitter compiles the unwrapped identifier or tag, never\n// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError\n// before the chain_walk fix).\nconst Plain = (props: { className?: string }) => ;\n\nexport const AssertedBox = createComponent('div', 'animus-AssertedBox-4eb5588e', {});\n\nexport const AssertedLink = createComponent(Plain, 'animus-AssertedLink-4cf7ebf5', {});\n\nexport const App = () => (\n \n \n \n);\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-AssertedBox-4eb5588e {\n padding: 0.5rem;\n display: flex;\n }\n .animus-AssertedLink-4cf7ebf5 {\n font-weight: 600;\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "inline-asserted-targets.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "inline-asserted-targets.tsx::AssertedBox", + "inline-asserted-targets.tsx::AssertedLink" + ], + "componentFragmentsJson": "{\"inline-asserted-targets.tsx::AssertedBox\":{\"base\":\" .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n\"},\"inline-asserted-targets.tsx::AssertedLink\":{\"base\":\" .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, "parity/keyframes-import": { "code": { "anim.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\nimport { motion } from './system';\n\nexport const Pulse = createComponent('div', 'animus-Pulse-cbd4b392', {});\nexport const App = () => ;\n\n", diff --git a/packages/_parity/baselines/v2/production.json b/packages/_parity/baselines/v2/production.json index 6499b8e9..2631038e 100644 --- a/packages/_parity/baselines/v2/production.json +++ b/packages/_parity/baselines/v2/production.json @@ -1,8 +1,8 @@ { - "corpusSha256": "dcc22235874fcc95d2bc65d0dc00ce11b61100c0a80e46fe14c34a9b9ac7116b", + "corpusSha256": "bce35dce13dd19571bd5ec514c29e0434e0686cdc8c1db9745af0be597f4916f", "engine": "v2", "mode": "production", - "refreshIntent": "ani-fix-witness-fixtures-20260803", + "refreshIntent": "ani-closeout-fixture-batch-20260803", "surfaceSchemaSha256": "43eb1265e97aab8497c11ca05e8a4159a6d4f4b69a6837a704e1d96fa75eb108", "units": { "extract-all": { @@ -710,6 +710,27 @@ }, "parseCount": 1 }, + "parity/color-family-pass-through.tsx": { + "code": { + "color-family-pass-through.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-009 witness: raw CSS color-property names resolve semantic tokens via\n// COLOR_FAMILY_PASS_THROUGH — the DS registers `bg`, not `backgroundColor`,\n// yet the longhand must reach the colors scale at top level and in\n// responsive slots, while non-token values pass through literally.\nexport const PassThrough = createComponent('section', 'animus-PassThrough-a05c8778', {});\n\nexport const App = () => ;\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-PassThrough-a05c8778 {\n border-top-color: rgb(1 2 3);\n color: var(--color-primary);\n background-color: var(--color-primary);\n }\n @media (min-width: 768px) {\n .animus-PassThrough-a05c8778 {\n color: var(--color-secondary);\n }\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "color-family-pass-through.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "color-family-pass-through.tsx::PassThrough" + ], + "componentFragmentsJson": "{\"color-family-pass-through.tsx::PassThrough\":{\"base\":\" .animus-PassThrough-a05c8778 {\\n border-top-color: rgb(1 2 3);\\n color: var(--color-primary);\\n background-color: var(--color-primary);\\n }\\n @media (min-width: 768px) {\\n .animus-PassThrough-a05c8778 {\\n color: var(--color-secondary);\\n }\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-PassThrough-a05c8778 {\\n border-top-color: rgb(1 2 3);\\n color: var(--color-primary);\\n background-color: var(--color-primary);\\n }\\n @media (min-width: 768px) {\\n .animus-PassThrough-a05c8778 {\\n color: var(--color-secondary);\\n }\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, "parity/compose-container-card.tsx": { "code": { "compose-container-card.tsx": "import { createComponent, createComposedFamily } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// Corpus fixture (modern-css-surface inc 08) — blessed into the committed oracle.\n// Canonical compose-slot CONTAINER pattern: the Root slot establishes a named\n// query container (container-name/container-type — design D7 pass-through\n// declarations) and slotted children respond via raw `@container card (…)`\n// block keys (design D2, no registration). Mirrors the landed test-ds\n// `ContainerCard` family; here as a parity oracle unit so the compose ×\n// container-establishment × @container-response combination is byte-pinned.\n\nimport { ds } from '../test-system';\n\nconst Root = createComponent('article', 'animus-Root-b4b4101f', {});\n\nconst Media = createComponent('div', 'animus-Media-cdf8bb75', {});\n\nconst Body = createComponent('div', 'animus-Body-c5ef664b', {});\n\nexport const ContainerCard = createComposedFamily({ Root: Root, Media: Media, Body: Body }, { name: \"ContainerCard\" });\nexport const App = () => (\n \n \n \n \n);\n\n" @@ -1184,6 +1205,28 @@ }, "parseCount": 2 }, + "parity/inline-asserted-targets.tsx": { + "code": { + "inline-asserted-targets.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-015 witness: inline type assertions on terminal targets extract\n// exactly like their bare forms — the walker unwraps as/satisfies/non-null\n// wrappers and the emitter compiles the unwrapped identifier or tag, never\n// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError\n// before the chain_walk fix).\nconst Plain = (props: { className?: string }) => ;\n\nexport const AssertedBox = createComponent('div', 'animus-AssertedBox-4eb5588e', {});\n\nexport const AssertedLink = createComponent(Plain, 'animus-AssertedLink-4cf7ebf5', {});\n\nexport const App = () => (\n \n \n \n);\n\n" + }, + "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-AssertedBox-4eb5588e {\n padding: 0.5rem;\n display: flex;\n }\n .animus-AssertedLink-4cf7ebf5 {\n font-weight: 600;\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", + "diagnostics": [], + "hasComponents": { + "inline-asserted-targets.tsx": true + }, + "observables": { + "componentFragmentKeys": [ + "inline-asserted-targets.tsx::AssertedBox", + "inline-asserted-targets.tsx::AssertedLink" + ], + "componentFragmentsJson": "{\"inline-asserted-targets.tsx::AssertedBox\":{\"base\":\" .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n\"},\"inline-asserted-targets.tsx::AssertedLink\":{\"base\":\" .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n\"}}", + "dynamicPropsJson": "{}", + "reverseProvenanceEdges": [], + "sheetsJson": "{\"base\":\"@layer anm-base {\\n .animus-AssertedBox-4eb5588e {\\n padding: 0.5rem;\\n display: flex;\\n }\\n .animus-AssertedLink-4cf7ebf5 {\\n font-weight: 600;\\n }\\n}\\n\",\"compounds\":\"\",\"custom\":\"\",\"declaration\":\"@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\\n\",\"global\":\"@layer anm-global {\\nbody {\\n font-family: {fonts.base};\\n margin: 0;\\n}\\n@keyframes anm-ember {\\n 0% {\\n opacity: 0;\\n }\\n 100% {\\n opacity: 1;\\n }\\n}\\n}\\n\",\"states\":\"\",\"system\":\"\",\"variants\":\"@layer anm-variants {\\n @layer standalone, composed;\\n @layer composed {\\n }\\n}\\n\"}", + "systemPropMapJson": "{}" + }, + "parseCount": 1 + }, "parity/keyframes-import": { "code": { "anim.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\nimport { motion } from './system';\n\nexport const Pulse = createComponent('div', 'animus-Pulse-cbd4b392', {});\nexport const App = () => ;\n\n", diff --git a/packages/_parity/corpus/color-family-pass-through.tsx b/packages/_parity/corpus/color-family-pass-through.tsx new file mode 100644 index 00000000..61387b71 --- /dev/null +++ b/packages/_parity/corpus/color-family-pass-through.tsx @@ -0,0 +1,13 @@ +// ANI-009 witness: raw CSS color-property names resolve semantic tokens via +// COLOR_FAMILY_PASS_THROUGH — the DS registers `bg`, not `backgroundColor`, +// yet the longhand must reach the colors scale at top level and in +// responsive slots, while non-token values pass through literally. +export const PassThrough = ds + .styles({ + backgroundColor: 'primary', + borderTopColor: 'rgb(1 2 3)', + color: { _: 'primary', sm: 'secondary' }, + }) + .asElement('section'); + +export const App = () => ; diff --git a/packages/_parity/corpus/inline-asserted-targets.tsx b/packages/_parity/corpus/inline-asserted-targets.tsx new file mode 100644 index 00000000..20bbc899 --- /dev/null +++ b/packages/_parity/corpus/inline-asserted-targets.tsx @@ -0,0 +1,20 @@ +// ANI-015 witness: inline type assertions on terminal targets extract +// exactly like their bare forms — the walker unwraps as/satisfies/non-null +// wrappers and the emitter compiles the unwrapped identifier or tag, never +// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError +// before the chain_walk fix). +const Plain = (props: { className?: string }) => ; + +export const AssertedBox = ds + .styles({ display: 'flex', p: 8 }) + .asElement('div' as const); + +export const AssertedLink = ds + .styles({ fontWeight: 600 }) + .asComponent(Plain as typeof Plain); + +export const App = () => ( + + + +); diff --git a/packages/_parity/scoreboard.snap b/packages/_parity/scoreboard.snap index a7106015..daf4b594 100644 --- a/packages/_parity/scoreboard.snap +++ b/packages/_parity/scoreboard.snap @@ -1,6 +1,6 @@ parity baseline — engines: baseline:v2 vs v2 — devMode: false -Units passed: 62/62 (100.00%) +Units passed: 64/64 (100.00%) Divergences: 0 (0 unregistered) Usage-case families: @@ -23,7 +23,7 @@ Usage-case families: parity baseline — engines: baseline:v2 vs v2 — devMode: true -Units passed: 62/62 (100.00%) +Units passed: 64/64 (100.00%) Divergences: 0 (0 unregistered) Usage-case families: diff --git a/packages/_parity/self-check.snap b/packages/_parity/self-check.snap index d4aa68c4..50fec24b 100644 --- a/packages/_parity/self-check.snap +++ b/packages/_parity/self-check.snap @@ -1,6 +1,6 @@ parity self-check — engines: v2 vs v2 — devMode: false -Units passed: 62/62 (100.00%) +Units passed: 64/64 (100.00%) Divergences: 0 (0 unregistered) Usage-case families: @@ -23,7 +23,7 @@ Usage-case families: parity self-check — engines: v2 vs v2 — devMode: true -Units passed: 62/62 (100.00%) +Units passed: 64/64 (100.00%) Divergences: 0 (0 unregistered) Usage-case families: From d1ef92e0907095eefae3214d48fc414f638af119 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Mon, 3 Aug 2026 22:58:27 -0400 Subject: [PATCH 7/8] ani-ledger-closeout inc-04: OIDC publishing, packer parity, CI hygiene Release job converts to npm Trusted Publishing: id-token: write, NODE_AUTH_TOKEN and npm whoami removed, npm >= 11.5.1 floor asserted; provenance is automatic under OIDC. Owner must register ci.yaml as trusted publisher for all five packages on npmjs.com before the next tag. Pack step switches to bun pm pack - the same packer verify:packed proves on every push (npm pack shipped two release-only bugs in v0.1.2). Action majors: checkout v7, setup-node v7, upload-artifact v7, download-artifact v8 (Node 20 deprecation). Receipt uploads fail loud on showcase/next/packed lanes; verify-vite keeps warn plus an ls diagnostic until its empty-artifact cause is decided by a real run. ci-graph pins updated: OIDC permissions + no-token assertion, bun packer command, new artifact action versions. Verified: vp run verify:full green. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yaml | 111 +++++++++++++++++++------------- scripts/verify/ci-graph.test.ts | 22 ++++++- 2 files changed, 87 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2940fd18..2efd6d78 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,9 +27,9 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version-file: .tool-versions @@ -54,7 +54,7 @@ jobs: test-rust: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable @@ -77,7 +77,7 @@ jobs: clippy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@1.97.0 with: @@ -102,7 +102,7 @@ jobs: hygiene-rust: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable @@ -142,7 +142,7 @@ jobs: runs-on: ${{ matrix.runner }} continue-on-error: ${{ matrix.optional || false }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable @@ -165,7 +165,7 @@ jobs: bunx @napi-rs/cli build --platform --release --target ${{ matrix.target }} - name: Upload v2 binary - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: napi-v2-${{ matrix.target }} path: packages/extract/crates/extract-v2/*.node @@ -176,9 +176,9 @@ jobs: needs: [build-extract, test-rust] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version-file: .tool-versions @@ -189,7 +189,7 @@ jobs: - name: Download v2 linux binary # @animus-ui/showcase#verify:build runs on engine v2 (default since the # 2026-07-13 flip) — the v2 binary is a hard precondition. - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: napi-v2-x86_64-unknown-linux-gnu path: packages/extract/crates/extract-v2/ @@ -233,19 +233,20 @@ jobs: bunx vp run @animus-ui/showcase#verify:assert - name: Upload lane receipts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: receipts-showcase path: packages/showcase/.receipts/ + if-no-files-found: error # ─── Next consumer lane (build + assert on every push) ── verify-next: needs: [build-extract] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version-file: .tool-versions @@ -254,7 +255,7 @@ jobs: bun-version-file: .tool-versions - name: Download v2 linux binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: napi-v2-x86_64-unknown-linux-gnu path: packages/extract/crates/extract-v2/ @@ -270,19 +271,20 @@ jobs: bunx vp run @animus-ui/next-app#verify:assert - name: Upload lane receipts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: receipts-next path: e2e/next-app/.receipts/ + if-no-files-found: error # ─── Vite consumer lane (build + assert on every push) ── verify-vite: needs: [build-extract] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version-file: .tool-versions @@ -291,7 +293,7 @@ jobs: bun-version-file: .tool-versions - name: Download v2 linux binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: napi-v2-x86_64-unknown-linux-gnu path: packages/extract/crates/extract-v2/ @@ -306,8 +308,14 @@ jobs: bunx vp run @animus-ui/vite-app#verify:build bunx vp run @animus-ui/vite-app#verify:assert + - name: List lane receipts (diagnosing empty artifact) + # The receipt writes locally and next-app's analog uploads fine; + # this ls decides where the CI-side write goes missing. Flip the + # upload below to if-no-files-found: error once diagnosed. + run: ls -la e2e/vite-app/.receipts/ || echo "receipts dir absent" + - name: Upload lane receipts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: receipts-vite path: e2e/vite-app/.receipts/ @@ -317,9 +325,9 @@ jobs: needs: [build-extract] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version-file: .tool-versions @@ -328,7 +336,7 @@ jobs: bun-version-file: .tool-versions - name: Download v2 linux binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: napi-v2-x86_64-unknown-linux-gnu path: packages/extract/crates/extract-v2/ @@ -349,9 +357,9 @@ jobs: needs: [build-extract] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version-file: .tool-versions @@ -360,7 +368,7 @@ jobs: bun-version-file: .tool-versions - name: Download v2 linux binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: napi-v2-x86_64-unknown-linux-gnu path: packages/extract/crates/extract-v2/ @@ -374,10 +382,11 @@ jobs: run: bunx vp run verify:packed - name: Upload lane receipts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: receipts-packed path: e2e/packed-app/.staging/receipts/ + if-no-files-found: error # ─── Worker deployment (main push, schedule, or explicit dispatch) ── deploy-workers: @@ -404,9 +413,9 @@ jobs: cancel-in-progress: false runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version-file: .tool-versions @@ -415,7 +424,7 @@ jobs: bun-version-file: .tool-versions - name: Download v2 linux binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: napi-v2-x86_64-unknown-linux-gnu path: packages/extract/crates/extract-v2/ @@ -449,27 +458,40 @@ jobs: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && inputs.publish_packages == true) runs-on: ubuntu-latest - env: - NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} + # Trusted Publishing (OIDC): npm mints short-lived credentials from the + # workflow's identity token — no NODE_AUTH_TOKEN secret, no expiring + # bypass-2FA tokens (which lose direct publish ~Jan 2027). Each package + # must have this workflow registered as its trusted publisher on + # npmjs.com (owner-side, per package: org codecaaron, repo animus, + # workflow filename ci.yaml). + permissions: + contents: read + id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: oven-sh/setup-bun@v2 with: bun-version-file: .tool-versions - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version-file: .tool-versions registry-url: 'https://registry.npmjs.org' scope: 'animus-ui' - env: - NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} - run: bun install - - name: Verify npm auth - run: npm whoami + - name: Verify npm toolchain supports trusted publishing + # npm >= 11.5.1 is the OIDC floor; whoami is meaningless pre-publish + # under OIDC (credentials are minted at publish time). + run: | + node --version + npm --version + if [ "$(printf '%s\n' 11.5.1 "$(npm --version)" | sort -V | head -n1)" != "11.5.1" ]; then + echo "ERROR: npm $(npm --version) < 11.5.1 — trusted publishing unsupported" >&2 + exit 1 + fi # ── Parse version from tag ── - name: Set version from tag @@ -513,19 +535,19 @@ jobs: # A target missing its v2 binary fails the release job — # never publish a partial matrix (engine-release-packaging). - name: Download darwin-arm64 v2 binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: napi-v2-aarch64-apple-darwin path: packages/extract/crates/extract-v2/ - name: Download linux-x64 v2 binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: napi-v2-x86_64-unknown-linux-gnu path: packages/extract/crates/extract-v2/ - name: Download linux-arm64 v2 binary - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: napi-v2-aarch64-unknown-linux-gnu path: packages/extract/crates/extract-v2/ @@ -542,9 +564,12 @@ jobs: exit 1 fi for pkg in properties system extract vite-plugin next-plugin; do - # ./ prefix is load-bearing: a bare packages/$pkg is parsed by npm - # as a github:owner/repo shorthand, not a local folder. - npm pack "./packages/$pkg" --pack-destination "$RELEASE_BUNDLE" --ignore-scripts + # bun pm pack — the SAME packer verify:packed proves on every + # push (packer parity: npm pack shipped two release-only bugs in + # v0.1.2 because the lane packed differently). bun also resolves + # workspace: specifiers in every dependency block at pack time; + # the jq rewrite above stays as the version bump + belt. + (cd "packages/$pkg" && bun pm pack --destination "$RELEASE_BUNDLE") done for artifact in \ "animus-ui-extract-${VERSION}.tgz" \ diff --git a/scripts/verify/ci-graph.test.ts b/scripts/verify/ci-graph.test.ts index 4e98030a..737b958b 100644 --- a/scripts/verify/ci-graph.test.ts +++ b/scripts/verify/ci-graph.test.ts @@ -402,7 +402,7 @@ describe('parsed CI graph', () => { const job = jobs[jobName]; expect(job, jobName).toBeDefined(); expect(namedStep(job, 'Download v2 linux binary')).toMatchObject({ - uses: 'actions/download-artifact@v4', + uses: 'actions/download-artifact@v8', with: { name: 'napi-v2-x86_64-unknown-linux-gnu', path: 'packages/extract/crates/extract-v2/', @@ -444,7 +444,7 @@ describe('parsed CI graph', () => { } as const; for (const [jobName, [name, path]] of Object.entries(receipts)) { const upload = namedStep(jobs[jobName], 'Upload lane receipts'); - expect(upload.uses).toBe('actions/upload-artifact@v4'); + expect(upload.uses).toBe('actions/upload-artifact@v7'); expect(upload.with).toMatchObject({ name, path }); } @@ -475,6 +475,19 @@ describe('parsed CI graph', () => { } }); + it('publishes via trusted publishing: id-token scoped to the release job', () => { + const { jobs } = readWorkflow(); + // OIDC floor (ani-ledger-closeout inc-04): the release job mints its + // npm credentials from the workflow identity - no NODE_AUTH_TOKEN. + expect(jobs.release.permissions).toEqual({ + contents: 'read', + 'id-token': 'write', + }); + const releaseYaml = JSON.stringify(jobs.release); + expect(releaseYaml).not.toContain('NODE_AUTH_TOKEN'); + expect(releaseYaml).not.toContain('npm whoami'); + }); + it('keeps immutable release bundle materialize, verify, and publication order', () => { const release = readWorkflow().jobs.release; const pack = namedStep(release, 'Pack immutable release bundle'); @@ -488,8 +501,11 @@ describe('parsed CI graph', () => { expect(verifyIndex).toBeLessThan(publishIndex); // The ./ prefix is load-bearing: npm parses a bare packages/$pkg as a // github:owner/repo shorthand, not a local folder (v0.1.2 release outage). + // Packer parity: the release bundles with the SAME packer the + // verify:packed lane proves on every push (bun pm pack — npm pack + // shipped two release-only bugs in v0.1.2). expect(pack.run).toContain( - 'npm pack "./packages/$pkg" --pack-destination "$RELEASE_BUNDLE" --ignore-scripts' + '(cd "packages/$pkg" && bun pm pack --destination "$RELEASE_BUNDLE")' ); // retire-extract-v1: no v1 platform sub-packages are packed or published; // v2 binaries ship inside the main extract tarball. From c9395c1869dda61cf874eb6c84c57dd1457f8bae Mon Sep 17 00:00:00 2001 From: codecaaron Date: Mon, 3 Aug 2026 23:00:33 -0400 Subject: [PATCH 8/8] ani-ledger-closeout inc-03/04 fixup: a11y-lint fix in corpus fixture; verify:full now green Record correction: the previous commit's verify:full claim was wrong - that run failed on verify:lint (jsx-a11y anchor-has-content in the new inline-asserted-targets fixture; sibling 137s were vp cancellation). The asComponent target is now a span; only the unit's code artifact drifted (CSS and observables unchanged), re-refreshed under the same intent ani-closeout-fixture-batch-20260803 and the register cleared after absorption. vp run verify:full exits 0 on this tree. Co-Authored-By: Claude Fable 5 --- packages/_parity/baselines/v2/development.json | 4 ++-- packages/_parity/baselines/v2/production.json | 4 ++-- packages/_parity/corpus/inline-asserted-targets.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/_parity/baselines/v2/development.json b/packages/_parity/baselines/v2/development.json index 28e854ad..3c786118 100644 --- a/packages/_parity/baselines/v2/development.json +++ b/packages/_parity/baselines/v2/development.json @@ -1,5 +1,5 @@ { - "corpusSha256": "bce35dce13dd19571bd5ec514c29e0434e0686cdc8c1db9745af0be597f4916f", + "corpusSha256": "017faab6c9e37c032c38dbdec1a86e3df3bdf1726fdbc53fc287f20246da817a", "engine": "v2", "mode": "development", "refreshIntent": "ani-closeout-fixture-batch-20260803", @@ -1245,7 +1245,7 @@ }, "parity/inline-asserted-targets.tsx": { "code": { - "inline-asserted-targets.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-015 witness: inline type assertions on terminal targets extract\n// exactly like their bare forms — the walker unwraps as/satisfies/non-null\n// wrappers and the emitter compiles the unwrapped identifier or tag, never\n// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError\n// before the chain_walk fix).\nconst Plain = (props: { className?: string }) => ;\n\nexport const AssertedBox = createComponent('div', 'animus-AssertedBox-4eb5588e', {});\n\nexport const AssertedLink = createComponent(Plain, 'animus-AssertedLink-4cf7ebf5', {});\n\nexport const App = () => (\n \n \n \n);\n\n" + "inline-asserted-targets.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-015 witness: inline type assertions on terminal targets extract\n// exactly like their bare forms — the walker unwraps as/satisfies/non-null\n// wrappers and the emitter compiles the unwrapped identifier or tag, never\n// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError\n// before the chain_walk fix).\nconst Plain = (props: { className?: string }) => ;\n\nexport const AssertedBox = createComponent('div', 'animus-AssertedBox-4eb5588e', {});\n\nexport const AssertedLink = createComponent(Plain, 'animus-AssertedLink-4cf7ebf5', {});\n\nexport const App = () => (\n \n \n \n);\n\n" }, "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-AssertedBox-4eb5588e {\n padding: 0.5rem;\n display: flex;\n }\n .animus-AssertedLink-4cf7ebf5 {\n font-weight: 600;\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", "diagnostics": [], diff --git a/packages/_parity/baselines/v2/production.json b/packages/_parity/baselines/v2/production.json index 2631038e..215c21fe 100644 --- a/packages/_parity/baselines/v2/production.json +++ b/packages/_parity/baselines/v2/production.json @@ -1,5 +1,5 @@ { - "corpusSha256": "bce35dce13dd19571bd5ec514c29e0434e0686cdc8c1db9745af0be597f4916f", + "corpusSha256": "017faab6c9e37c032c38dbdec1a86e3df3bdf1726fdbc53fc287f20246da817a", "engine": "v2", "mode": "production", "refreshIntent": "ani-closeout-fixture-batch-20260803", @@ -1207,7 +1207,7 @@ }, "parity/inline-asserted-targets.tsx": { "code": { - "inline-asserted-targets.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-015 witness: inline type assertions on terminal targets extract\n// exactly like their bare forms — the walker unwraps as/satisfies/non-null\n// wrappers and the emitter compiles the unwrapped identifier or tag, never\n// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError\n// before the chain_walk fix).\nconst Plain = (props: { className?: string }) => ;\n\nexport const AssertedBox = createComponent('div', 'animus-AssertedBox-4eb5588e', {});\n\nexport const AssertedLink = createComponent(Plain, 'animus-AssertedLink-4cf7ebf5', {});\n\nexport const App = () => (\n \n \n \n);\n\n" + "inline-asserted-targets.tsx": "import { createComponent } from '@animus-ui/system';\nimport 'virtual:animus/styles.css';\n// ANI-015 witness: inline type assertions on terminal targets extract\n// exactly like their bare forms — the walker unwraps as/satisfies/non-null\n// wrappers and the emitter compiles the unwrapped identifier or tag, never\n// a placeholder (`createComponent(unknown, …)` was a browser ReferenceError\n// before the chain_walk fix).\nconst Plain = (props: { className?: string }) => ;\n\nexport const AssertedBox = createComponent('div', 'animus-AssertedBox-4eb5588e', {});\n\nexport const AssertedLink = createComponent(Plain, 'animus-AssertedLink-4cf7ebf5', {});\n\nexport const App = () => (\n \n \n \n);\n\n" }, "css": "@layer anm-global, anm-base, anm-variants, anm-compounds, anm-states, anm-system, anm-custom;\n\n@layer anm-base {\n .animus-AssertedBox-4eb5588e {\n padding: 0.5rem;\n display: flex;\n }\n .animus-AssertedLink-4cf7ebf5 {\n font-weight: 600;\n }\n}\n\n@layer anm-variants {\n @layer standalone, composed;\n @layer composed {\n }\n}\n\n", "diagnostics": [], diff --git a/packages/_parity/corpus/inline-asserted-targets.tsx b/packages/_parity/corpus/inline-asserted-targets.tsx index 20bbc899..b27fec89 100644 --- a/packages/_parity/corpus/inline-asserted-targets.tsx +++ b/packages/_parity/corpus/inline-asserted-targets.tsx @@ -3,7 +3,7 @@ // wrappers and the emitter compiles the unwrapped identifier or tag, never // a placeholder (`createComponent(unknown, …)` was a browser ReferenceError // before the chain_walk fix). -const Plain = (props: { className?: string }) => ; +const Plain = (props: { className?: string }) => ; export const AssertedBox = ds .styles({ display: 'flex', p: 8 })