From 858ca818ee69273d2fe423cc8050e39c5ca208a8 Mon Sep 17 00:00:00 2001 From: Noel Tock Date: Mon, 7 Sep 2026 18:42:25 +0700 Subject: [PATCH] fix(author): report actionable source-bound diagnostics --- src/author/diagnostics.ts | 37 +++++++ src/author/index.ts | 185 +++++++++++++++++++++++++++++--- src/author/plan.ts | 47 +++++++- src/author/proposal.ts | 28 ++++- test/author.diagnostics.test.ts | 156 +++++++++++++++++++++++++++ test/author.proposal.test.ts | 10 +- 6 files changed, 443 insertions(+), 20 deletions(-) create mode 100644 src/author/diagnostics.ts create mode 100644 test/author.diagnostics.test.ts diff --git a/src/author/diagnostics.ts b/src/author/diagnostics.ts new file mode 100644 index 0000000..83c931a --- /dev/null +++ b/src/author/diagnostics.ts @@ -0,0 +1,37 @@ +import type { SourceLocation } from '../types.js'; + +/** Small internal transport for structured authoring failures through the existing Error catches. */ +export interface AuthorDiagnostic { + code: string; + reason: string; + source?: SourceLocation; + details?: unknown; +} + +export class AuthorDiagnosticError extends Error { + constructor(readonly diagnostics: readonly AuthorDiagnostic[]) { + super(diagnostics[0]?.reason ?? 'authoring diagnostic'); + this.name = 'AuthorDiagnosticError'; + } +} + +export function authorDiagnostic( + code: string, + reason: string, + source?: SourceLocation, + details?: unknown, +): AuthorDiagnosticError { + return new AuthorDiagnosticError([{ code, reason, ...(source ? { source: reportDiagnosticSource(source) } : {}), ...(details === undefined ? {} : { details }) }]); +} + +/** Normalize parser/source records at the report boundary without manufacturing a position. */ +export function reportDiagnosticSource(source: SourceLocation): SourceLocation { + const value = source as SourceLocation & { line?: number; column?: number }; + return { + ...(value.path === undefined ? {} : { path: value.path }), + ...(value.selector === undefined ? {} : { selector: value.selector }), + ...(value.offset === undefined ? {} : { offset: value.offset }), + ...((value.htmlLine ?? value.line) === undefined ? {} : { htmlLine: value.htmlLine ?? value.line }), + ...((value.htmlColumn ?? value.column) === undefined ? {} : { htmlColumn: value.htmlColumn ?? value.column }), + }; +} diff --git a/src/author/index.ts b/src/author/index.ts index 0546dd2..3a2af8e 100644 --- a/src/author/index.ts +++ b/src/author/index.ts @@ -39,6 +39,7 @@ import { } from './plan.js'; import { validateSourceContent } from './content.js'; import { bindAuthoringProposal, normalizeAuthoringProposal, validateProposalSourceContent } from './proposal.js'; +import { AuthorDiagnosticError, reportDiagnosticSource } from './diagnostics.js'; import { compileRegisteredBlock } from '../authoring/generate.js'; import { COMPONENT_FOUNDATION_WARNING, validateAuthoringPlan } from '../authoring/schema.js'; import { authoringRulesFromStylesheet } from '../authoring/styles.js'; @@ -158,7 +159,8 @@ export async function author(input: string, options: AuthorOptions = {}): Promis block: name, status: 'warning' as const, reason: issue.reason, - details: { outcome: issue.status, field: issue.field }, + ...environmentReportFields(issue.reason), + details: { outcome: issue.status, field: issue.field, ...environmentReportDetails(issue.reason) }, })); if (compiled.css) { // In source mode this is the CSS that is scanned, scoped, and made available to the @@ -251,7 +253,7 @@ export async function author(input: string, options: AuthorOptions = {}): Promis : asset); sharedFonts = prepareAuthoringFonts(styleInput, name, [...preparedAssets.values()], fontBindingAssets, scanStylesheet(styleInput)); } catch (error) { - return authorFailure(error instanceof Error ? error.message : String(error), source); + return authorFailure(error, source); } styleInput = sharedFonts.css; if (editorStyleInput !== undefined) { @@ -482,7 +484,7 @@ export async function author(input: string, options: AuthorOptions = {}): Promis selectorDependencies: selectorTransport.dependencies, }); } catch (error) { - return authorFailure(error instanceof Error ? error.message : String(error), source, evidence); + return authorFailure(error, source, evidence); } } let compiled: ReturnType | undefined; @@ -510,7 +512,7 @@ export async function author(input: string, options: AuthorOptions = {}): Promis throw new Error(`Supplied authoring plan target ${supplied.target.name} does not match requested block ${name}.`); } if (!supplied.coverage || stableJson(supplied.coverage) !== stableJson(expectedCoverage)) { - throw new Error('Supplied authoring plan does not retain the complete source declaration and asset coverage.'); + throw coverageDifferenceError(supplied.coverage, expectedCoverage); } validateCoverageFulfillment(supplied, input, assetRoot); compiled = { @@ -519,7 +521,7 @@ export async function author(input: string, options: AuthorOptions = {}): Promis editorStyleLedger: [], } as ReturnType; } catch (error) { - generationItems.push(authoringFailureItem(name, error)); + generationItems.push(...authoringFailureItems(name, error)); compiled = undefined; } } else if (!hardAssetFailure && !hardStyleFailure && !hardInlineStyleFailure && (conversion.ok || proposal)) { @@ -560,7 +562,7 @@ export async function author(input: string, options: AuthorOptions = {}): Promis compiled = { ...compiled, plan, generated: compileRegisteredBlock(plan) }; } } catch (error) { - generationItems.push(authoringFailureItem(name, error)); + generationItems.push(...authoringFailureItems(name, error)); // Compilation may have produced a tentative package before canonical coverage/content // fulfillment runs. Never return that tentative package after a fail-closed validation. compiled = undefined; @@ -729,17 +731,18 @@ async function collectNativeSourceDeclarations( } function authorFailure( - reason: string, + failure: unknown, source?: BlockRunnerReport['source'], evidence?: AuthorSourceEvidence, ): BlockRunnerReport { + const items = authoringFailureItems('input', failure); return { ok: false, command: 'author', ...(source ? { source } : {}), ...(evidence ? { evidence } : {}), - summary: { blocks: 0, valid: 0, invalid: 0, warnings: 1 }, - items: [{ block: 'input', status: 'warning', reason }], + summary: { blocks: 0, valid: 0, invalid: 0, warnings: items.length }, + items, }; } @@ -1491,20 +1494,178 @@ function normalizeStyleOutcome(value: string): AuthoredStyleLedgerEntry['outcome } /** Preserve the native-mapping category for callers instead of flattening it into a warning. */ +function authoringFailureItems(block: string, error: unknown): ReportItem[] { + if (error instanceof AuthorDiagnosticError) { + return error.diagnostics.map((diagnostic) => ({ block, status: 'warning', ...diagnostic })); + } + return [authoringFailureItem(block, error)]; +} + function authoringFailureItem(block: string, error: unknown): ReportItem { if (error instanceof UnresolvedNativeStyleMappingError) { const mapping = error.mapping; return { block, status: 'warning', code: error.code, reason: error.message, ...(mapping.htmlSource ? { source: { path: mapping.htmlSource.path, offset: mapping.htmlSource.offset, htmlLine: mapping.htmlSource.line, htmlColumn: mapping.htmlSource.column } } : {}), - details: mapping, + details: { + ...mapping, + classification: 'unsupported-native-style-mapping', + unsupportedReason: mapping.reason, + }, }; } const reason = error instanceof Error ? error.message : String(error); const mapping = reason.match(/^unresolved-native-style-mapping:\s*(.*)$/); + const environmentDetails = environmentReportDetails(error); return mapping - ? { block, status: 'warning', code: 'unresolved-native-style-mapping', reason, details: { mapping: mapping[1] } } - : { block, status: 'warning', reason }; + ? { block, status: 'warning', code: 'unresolved-native-style-mapping', reason, details: { mapping: mapping[1], classification: 'unsupported-native-style-mapping', unsupportedReason: mapping[1] } } + : { block, status: 'warning', ...environmentReportFields(error), reason, ...(environmentDetails ? { details: environmentDetails } : {}) }; +} + +function environmentReportFields(error: unknown): Pick { + return hostError(error) ? { code: 'author-environment-input' } : {}; +} + +function environmentReportDetails(error: unknown): Record | undefined { + const host = hostError(error); + return host ? { + classification: 'environment', hostCode: host.code, ...(host.path ? { path: host.path } : {}), + action: 'provide a readable local input dependency and rerun author(); do not rewrite the design', + } : undefined; +} + +function hostError(error: unknown): { code: 'ENOENT' | 'EACCES' | 'EPERM'; path?: string } | undefined { + if (error && typeof error === 'object') { + const value = error as { code?: unknown; path?: unknown; message?: unknown }; + if (value.code === 'ENOENT' || value.code === 'EACCES' || value.code === 'EPERM') { + return { code: value.code, ...(typeof value.path === 'string' ? { path: value.path } : {}) }; + } + if (typeof value.message === 'string') return hostError(value.message); + } + if (typeof error !== 'string') return undefined; + const match = /\b(ENOENT|EACCES|EPERM):/.exec(error); + const path = /'([^']+)'/.exec(error)?.[1]; + return match ? { code: match[1] as 'ENOENT' | 'EACCES' | 'EPERM', ...(path ? { path } : {}) } : undefined; +} + +const COVERAGE_DIAGNOSTIC_CAP = 12; + +/** + * Coverage remains an exact equality gate. These bounded differences only make the rejected + * source decision addressable; callers never need to manufacture the compiler-owned ledger. + */ +function coverageDifferenceError( + supplied: import('../authoring/schema.js').AuthoringCoverage | undefined, + expected: import('../authoring/schema.js').AuthoringCoverage, +): AuthorDiagnosticError { + const diagnostics: import('./diagnostics.js').AuthorDiagnostic[] = []; + const prefix = 'Supplied authoring plan does not retain the complete source declaration and asset coverage.'; + const styles = (coverage: import('../authoring/schema.js').AuthoringCoverage | undefined) => coverage?.styles ?? []; + const assets = (coverage: import('../authoring/schema.js').AuthoringCoverage | undefined) => coverage?.assets ?? []; + const styleKey = (entry: import('../authoring/schema.js').AuthoringCoverageStyle) => entry.declarationId + ?? `${entry.ruleId ?? ''}\u0000${entry.source?.selector ?? ''}\u0000${entry.source?.offset ?? ''}\u0000${entry.property}`; + const assetKey = (entry: import('../authoring/schema.js').AuthoringCoverageAsset) => `${entry.kind}\u0000${entry.reference}\u0000${entry.source?.offset ?? ''}`; + const add = ( + code: string, key: string, reason: string, expectedEntry: { source?: ReportItem['source'] } | undefined, + observedEntry: unknown, + classification: string, + ) => diagnostics.push({ + code, + reason: `${prefix} ${reason}`, + ...(expectedEntry?.source ? { source: reportDiagnosticSource(expectedEntry.source) } : {}), + details: { + key, expected: expectedEntry, observed: observedEntry, classification, + action: 'refresh source analysis, then resubmit a semantic proposal through author(); Block Runner owns coverage records', + }, + }); + const compare = ( + expectedEntries: readonly T[], suppliedEntries: readonly T[], keyFor: (entry: T) => string, kind: 'declaration' | 'asset', + ) => { + const expectedByKey = new Map(expectedEntries.map((entry) => [keyFor(entry), entry])); + const suppliedByKey = new Map(suppliedEntries.map((entry) => [keyFor(entry), entry])); + for (const [key, entry] of expectedByKey) { + const observed = suppliedByKey.get(key); + if (!observed) add(`coverage-missing-${kind}`, key, `Missing ${describeCoverageEntry(entry, kind)}.`, entry, undefined, `missing-${kind}`); + else if (stableJson(entry) !== stableJson(observed)) { + const expectedOwnership = stableJson(coverageOwnership(entry)); + const observedOwnership = stableJson(coverageOwnership(observed)); + add( + expectedOwnership === observedOwnership ? `coverage-changed-${kind}` : `coverage-contradictory-${kind}-ownership`, + key, + expectedOwnership === observedOwnership ? `Changed ${describeCoverageEntry(entry, kind)} values or conditions.` : `Contradictory ownership for ${describeCoverageEntry(entry, kind)}.`, + entry, observed, + expectedOwnership === observedOwnership ? `changed-${kind}` : 'contradictory-ownership', + ); + } + } + for (const [key, entry] of suppliedByKey) if (!expectedByKey.has(key)) { + add(`coverage-extra-${kind}`, key, `Extra ${describeCoverageEntry(entry, kind)}.`, entry, entry, `extra-${kind}`); + } + }; + compare(styles(expected), styles(supplied), styleKey, 'declaration'); + compare(assets(expected), assets(supplied), assetKey, 'asset'); + const expectedContext = { ...expected, styles: undefined, assets: undefined }; + const suppliedContext = supplied ? { ...supplied, styles: undefined, assets: undefined } : undefined; + if (stableJson(expectedContext) !== stableJson(suppliedContext)) { + add('coverage-changed-context', 'coverage-context', 'Changed source stylesheet or analysis context.', undefined, suppliedContext, 'changed-context'); + } + if (!diagnostics.length) { + const difference = firstCoverageDifference(expected, supplied); + add( + 'coverage-changed-context', + difference.key, + difference.reason, + difference.expected, + difference.observed, + 'changed-context', + ); + } + diagnostics.sort((left, right) => left.code.localeCompare(right.code) || String((left.details as { key?: string }).key ?? '').localeCompare(String((right.details as { key?: string }).key ?? ''))); + const total = diagnostics.length; + const categories = [...new Set(diagnostics.map((diagnostic) => diagnostic.code))]; + return new AuthorDiagnosticError(diagnostics.slice(0, COVERAGE_DIAGNOSTIC_CAP).map((diagnostic) => ({ + ...diagnostic, + details: { ...(diagnostic.details as object), total, truncated: Math.max(0, total - COVERAGE_DIAGNOSTIC_CAP), categories }, + }))); +} + +function describeCoverageEntry(entry: unknown, kind: 'declaration' | 'asset'): string { + if (!entry || typeof entry !== 'object') return kind; + const value = entry as Record; + return kind === 'declaration' + ? `declaration ${String(value.property ?? '')}: ${String(value.value ?? '')}` + : `asset ${String(value.reference ?? '')}`; +} + +function firstCoverageDifference( + expected: import('../authoring/schema.js').AuthoringCoverage, + supplied: import('../authoring/schema.js').AuthoringCoverage | undefined, +): { key: string; reason: string; expected?: { source?: ReportItem['source'] }; observed?: unknown } { + for (const [kind, expectedEntries, suppliedEntries] of [ + ['declaration', expected.styles, supplied?.styles ?? []], + ['asset', expected.assets, supplied?.assets ?? []], + ] as const) { + const max = Math.max(expectedEntries.length, suppliedEntries.length); + for (let index = 0; index < max; index += 1) { + const expectedEntry = expectedEntries[index]; + const observedEntry = suppliedEntries[index]; + if (stableJson(expectedEntry) !== stableJson(observedEntry)) { + return { + key: `${kind}-order-${index}`, + reason: `${kind === 'declaration' ? 'Declaration' : 'Asset'} order or duplicate identity differs at entry ${index + 1}${expectedEntry ? ` (${describeCoverageEntry(expectedEntry, kind)})` : ''}.`, + expected: expectedEntry, + observed: observedEntry, + }; + } + } + } + return { key: 'coverage-context', reason: 'Source stylesheet or analysis context changed.', observed: undefined }; +} + +function coverageOwnership(entry: unknown): unknown { + if (!entry || typeof entry !== 'object') return entry; + const { outcome, node, responsive, preset, nativeTargets, destination, sha256, rewritten, reason } = entry as Record; + return { outcome, node, responsive, preset, nativeTargets, destination, sha256, rewritten, reason }; } /** Carry compiler-owned adapter destinations across a fresh source scan only by full identity. */ diff --git a/src/author/plan.ts b/src/author/plan.ts index 80f63c0..02a3e8d 100644 --- a/src/author/plan.ts +++ b/src/author/plan.ts @@ -25,6 +25,7 @@ import { exactThemePresetTransport, styleContextFrom } from './style-context.js' import { mapExactWordPressResponsiveMedia, resolveWordPressViewportRanges } from './responsive.js'; import { hasUnsafeResponsiveNativeCascade } from './cascade.js'; import { sourceDeclarationKey } from '../styles/apply.js'; +import { authorDiagnostic } from './diagnostics.js'; export class UnresolvedNativeStyleMappingError extends Error { readonly code = 'unresolved-native-style-mapping' as const; @@ -930,7 +931,24 @@ export function validateCoverageFulfillment(plan: AuthoringPlan, sourceHtml?: st ? sourceSelectorApplies(entry.source.selector, (sourceDom ??= new JSDOM(sourceHtml)).window.document) : true; if (!generatedMatch && sourceMatch) { - throw new Error(`${label} is marked scoped-css but its selector does not match the generated native template.`); + throw authorDiagnostic( + 'coverage-unmatched-emitted-selector', + `${label} is marked scoped-css but its selector does not match the generated native template.`, + entry.source, + { + declarationId: entry.declarationId, + ruleId: entry.ruleId, + selector: entry.source?.selector, + transportSelector: entry.transportSelector, + property: entry.property, + value: entry.value, + scope: entry.scope, + node: entry.node, + nativeTargets: entry.nativeTargets, + classification: 'unmatched-emitted-selector', + action: 'map the source selector to an emitted native target or retain it on the bound node', + }, + ); } continue; } @@ -969,7 +987,17 @@ export function validateCoverageFulfillment(plan: AuthoringPlan, sourceHtml?: st const retainedByCss = [...plan.styles.rules ?? [], ...plan.styles.editorRules ?? []] .some((rule) => cssRuleUsesAsset(rule, entry.reference)); if (!retainedByNode && !retainedByCss && !isReviewedWholeMediaOmission(plan, sourceHtml, entry.reference)) { - throw new Error(`${label} external URL is not retained byte-for-byte by the final package.`); + throw authorDiagnostic( + 'coverage-missing-asset-use', + `${label} external URL is not retained byte-for-byte by the final package.`, + entry.source, + { + reference: entry.reference, + kind: entry.kind, + classification: 'missing-external-asset-use', + action: 'retain this external URL byte-for-byte in a supported native or structured CSS use', + }, + ); } continue; } @@ -988,7 +1016,20 @@ export function validateCoverageFulfillment(plan: AuthoringPlan, sourceHtml?: st // only when the final structured stylesheet still names the exact package destination. const cssUse = [...plan.styles.rules ?? [], ...plan.styles.editorRules ?? []] .some((rule) => cssRuleUsesAsset(rule, asset.destination!)); - if (!cssUse) throw new Error(`${label} has no native or confirmed CSS output use.`); + if (!cssUse) { + throw authorDiagnostic( + 'coverage-missing-asset-use', + `${label} has no native or confirmed CSS output use.`, + entry.source, + { + reference: entry.reference, + destination: asset.destination, + kind: entry.kind, + classification: 'missing-asset-use', + action: 'add a supported native or structured CSS use for this confirmed asset', + }, + ); + } } } } finally { diff --git a/src/author/proposal.ts b/src/author/proposal.ts index 64600a0..16b48b0 100644 --- a/src/author/proposal.ts +++ b/src/author/proposal.ts @@ -7,6 +7,7 @@ import { validateSourceContent } from './content.js'; import { compileRegisteredBlock } from '../authoring/generate.js'; import { retainSelectorDependencies } from '../convert/dom.js'; import type { SourceSelectorDependency } from '../types.js'; +import { authorDiagnostic } from './diagnostics.js'; export interface BoundAuthoringProposal extends AuthoringPlan { /** Internal only: ties a hash-bound source range to one emitted native node. */ @@ -55,7 +56,7 @@ export function bindAuthoringProposal(input: { const decisions = input.proposal.sourceDecisions ?? []; for (const decision of decisions) { if (!decision.sourceRef.startsWith(`${input.sourceHash}:`) || !index.has(decision.sourceRef)) { - throw new Error(`${referenceLocation(decision.sourceRef, input.sourceHash, input.sourceHtml, input.sourcePath)}: proposal decision sourceRef ${decision.sourceRef} is stale, missing, or belongs to another source`); + throw staleSourceRefDiagnostic('proposal decision sourceRef', decision.sourceRef, decision.node, input); } } const explicit = new Map(); @@ -69,9 +70,9 @@ export function bindAuthoringProposal(input: { const attributes: Record = { ...(node.attributes ?? {}) }; let element: Element | undefined; if (node.sourceRef) { - if (!node.sourceRef.startsWith(`${input.sourceHash}:`)) throw new Error(`${referenceLocation(node.sourceRef, input.sourceHash, input.sourceHtml, input.sourcePath)}: proposal sourceRef ${node.sourceRef} is stale or belongs to another source`); + if (!node.sourceRef.startsWith(`${input.sourceHash}:`)) throw staleSourceRefDiagnostic('proposal sourceRef', node.sourceRef, node.id, input); element = index.get(node.sourceRef); - if (!element) throw new Error(`${referenceLocation(node.sourceRef, input.sourceHash, input.sourceHtml, input.sourcePath)}: proposal sourceRef ${node.sourceRef} is missing or ambiguous`); + if (!element) throw staleSourceRefDiagnostic('proposal sourceRef', node.sourceRef, node.id, input); if (isSourceUnit(element) && !isCompatibleSourceBinding(element, node.block)) { throw new Error(`${describeElement(element, dom, input.sourcePath)}: proposal sourceRef ${node.sourceRef} cannot bind ${element.tagName.toLowerCase()} content to ${node.block}`); } @@ -210,6 +211,27 @@ function referenceLocation(ref: string, _hash: string, html: string, path?: stri return `${path ?? ''}:${before.split('\n').length}:${start - before.lastIndexOf('\n')} (offset ${start})`; } +/** A foreign hash is never treated as a current DOM binding or location. */ +function staleSourceRefDiagnostic( + label: string, + sourceRef: string, + node: string | undefined, + input: Parameters[0], +) { + return authorDiagnostic( + 'stale-proposal-source-ref', + `${label} ${sourceRef} is stale, missing, or belongs to another source`, + input.sourcePath ? { path: input.sourcePath } : undefined, + { + sourceRef, + ...(node ? { node } : {}), + classification: 'stale-source-reference', + action: 'refresh-source-analysis', + referenceVerified: false, + }, + ); +} + /** Proposal callers must either preserve all source content or carry an explicit reviewed change. */ export function validateProposalSourceContent(sourceHtml: string, bound: AuthoringPlan, plan: AuthoringPlan): void { const decisions = bound.sourceDecisions ?? []; diff --git a/test/author.diagnostics.test.ts b/test/author.diagnostics.test.ts new file mode 100644 index 0000000..18cc4bb --- /dev/null +++ b/test/author.diagnostics.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; +import { author, collectSourceEvidence } from '../src/index.js'; +import { AuthorDiagnosticError } from '../src/author/diagnostics.js'; +import { validateCoverageFulfillment } from '../src/author/plan.js'; +import type { AuthoringCoverageStyle, AuthoringPlan } from '../src/authoring/schema.js'; + +function sourceRef(html: string, tag: string): string { + return collectSourceEvidence(html).structure.find((entry) => entry.tag === tag)!.sourceRef!; +} + +describe('author diagnostics', () => { + it('locates stale proposal references without treating their old hash as a current binding', async () => { + const html = '

Hello

'; + const report = await author(html, { + sourcePath: '/tmp/block-runner-diagnostic/stale.html', + author: { name: 'example/diagnostic-stale' }, + proposal: { structure: [{ id: 'copy', block: 'core/paragraph', sourceRef: `${'0'.repeat(64)}:0-12` }] }, + }); + const item = report.items[0]!; + expect(report.ok).toBe(false); + expect(item).toMatchObject({ code: 'stale-proposal-source-ref', source: { path: '/tmp/block-runner-diagnostic/stale.html' } }); + expect(item.source).not.toHaveProperty('offset'); + expect(item.reason).not.toMatch(/offset|stale\.html:/i); + expect(item.details).toMatchObject({ sourceRef: `${'0'.repeat(64)}:0-12`, node: 'copy', action: 'refresh-source-analysis', referenceVerified: false }); + expect(JSON.parse(JSON.stringify(report)).items[0].reason).toMatch(/stale, missing, or belongs to another source/i); + }); + + it('reports deterministic coverage differences and resolves them when the canonical plan is restored', async () => { + const html = '

Hello

'; + const options = { author: { name: 'example/diagnostic-coverage', styles: { mode: 'css' as const, css: '.notice { color: red; }' } } }; + const resolved = await author(html, options); + expect(resolved.ok, JSON.stringify(resolved.items)).toBe(true); + const rejectedPlan = structuredClone(resolved.package!.canonicalPlan!); + rejectedPlan.coverage!.styles = []; + const first = await author(html, { ...options, plan: rejectedPlan }); + const second = await author(html, { ...options, plan: rejectedPlan }); + expect(first.ok).toBe(false); + expect(first.items).toEqual(second.items); + const item = first.items.find((candidate) => candidate.code === 'coverage-missing-declaration')!; + expect(item).toMatchObject({ source: { selector: '.notice', htmlLine: 1, htmlColumn: 11 } }); + expect(item.reason).toMatch(/complete source declaration and asset coverage.*Missing declaration/i); + expect(item.details).toMatchObject({ action: expect.stringMatching(/semantic proposal through author/i), total: expect.any(Number), truncated: expect.any(Number) }); + const corrected = await author(html, { ...options, plan: resolved.package!.canonicalPlan! }); + expect(corrected.ok, JSON.stringify(corrected.items)).toBe(true); + + const duplicate = structuredClone(resolved.package!.canonicalPlan!); + duplicate.coverage!.styles.push(structuredClone(duplicate.coverage!.styles[0]!)); + const duplicateReport = await author(html, { ...options, plan: duplicate }); + const duplicateItem = duplicateReport.items.find((candidate) => candidate.code === 'coverage-changed-context')!; + expect(duplicateItem.details).toMatchObject({ key: expect.stringMatching(/order/), observed: expect.any(Object) }); + expect((duplicateItem.details as Record).observed).not.toHaveProperty('styles'); + }); + + it('reports an unmatched emitted selector with its declaration source', async () => { + const html = '

Hello

'; + const options = { author: { name: 'example/diagnostic-selector', styles: { mode: 'css' as const, css: '.notice:hover { color: red; }' } } }; + const baseline = await author(html, options); + const plan = structuredClone(baseline.package!.canonicalPlan!); + plan.structure[0]!.attributes = {}; + const report = await author(html, { ...options, plan }); + expect(report.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'coverage-unmatched-emitted-selector', source: expect.objectContaining({ selector: '.notice:hover' }), details: expect.objectContaining({ property: 'color', action: expect.any(String) }) }), + ])); + }); + + it('names changed values, conditions, and ownership without asking for a coverage ledger', async () => { + const html = '

Hello

'; + const options = { author: { name: 'example/diagnostic-changes', styles: { mode: 'css' as const, css: '.notice { color: red; }' } } }; + const baseline = await author(html, options); + const change = (mutate: (entry: AuthoringCoverageStyle) => void) => { + const plan = structuredClone(baseline.package!.canonicalPlan!); + const entry = plan.coverage!.styles.find((candidate) => candidate.source?.selector === '.notice')!; + mutate(entry); + return author(html, { ...options, plan }); + }; + for (const [label, mutate, code] of [ + ['value', (entry: AuthoringCoverageStyle) => { entry.value = 'blue'; }, 'coverage-changed-declaration'], + ['condition', (entry: AuthoringCoverageStyle) => { entry.atRules = ['@media (min-width: 1px)']; }, 'coverage-changed-declaration'], + ['ownership', (entry: AuthoringCoverageStyle) => { entry.outcome = 'scoped-css'; }, 'coverage-contradictory-declaration-ownership'], + ] as const) { + const report = await change(mutate); + const item = report.items.find((candidate) => candidate.code === code)!; + expect(item.reason, label).toMatch(/declaration color: red/i); + expect(item.details).toMatchObject({ expected: expect.objectContaining({ property: 'color', value: 'red' }), observed: expect.any(Object) }); + } + }); + + it('caps large coverage sets while retaining total and category visibility', async () => { + const html = Array.from({ length: 13 }, (_, index) => `

Item ${index}

`).join(''); + const css = Array.from({ length: 13 }, (_, index) => `.c${index} { color: red; }`).join('\n'); + const options = { author: { name: 'example/diagnostic-cap', styles: { mode: 'css' as const, css } } }; + const baseline = await author(html, options); + expect(baseline.ok, JSON.stringify(baseline.items)).toBe(true); + const plan = structuredClone(baseline.package!.canonicalPlan!); + plan.coverage!.styles = []; + const report = await author(html, { ...options, plan }); + const diagnostics = report.items.filter((item) => item.code?.startsWith('coverage-')); + expect(diagnostics).toHaveLength(12); + expect(diagnostics[0]!.details).toMatchObject({ total: expect.any(Number), truncated: expect.any(Number), categories: ['coverage-missing-declaration'] }); + const details = diagnostics[0]!.details as { total: number; truncated: number }; + expect(details.total).toBeGreaterThan(12); + expect(details.truncated).toBe(details.total - 12); + }); + + it('does not mistake a node named ENOENT for a host failure', async () => { + const html = '

Original

'; + const report = await author(html, { + author: { name: 'example/diagnostic-name' }, + proposal: { structure: [{ id: 'ENOENT', block: 'core/paragraph', sourceRef: sourceRef(html, 'p'), attributes: { content: 'Changed' } }] }, + }); + expect(report.ok).toBe(false); + expect(report.items[0]!.reason).toContain('changes source content'); + expect(report.items[0]!.code).not.toBe('author-environment-input'); + }); + + it('distinguishes a missing local stylesheet dependency from a design correction', async () => { + const missing = 'definitely-missing-author-input.css'; + const report = await author('

Hello

', { + sourcePath: '/tmp/block-runner-diagnostic/design.html', + author: { name: 'example/diagnostic-environment', styles: { mode: 'tailwind', tailwind: { + cssEntries: [missing], imports: [], directives: [], sources: ['design.html'], safelist: [], plugins: [], environment: {}, browserTarget: 'defaults', + compiler: { name: 'tailwindcss', version: 'test', compile: () => '' }, + } } }, + }); + const item = report.items.find((candidate) => candidate.code === 'author-environment-input')!; + expect(item, JSON.stringify(report.items)).toBeDefined(); + expect(item.reason).toMatch(/ENOENT.*definitely-missing-author-input\.css/i); + expect(item.details).toMatchObject({ classification: 'environment', hostCode: 'ENOENT', path: expect.stringMatching(/definitely-missing-author-input\.css$/), action: expect.stringMatching(/do not rewrite the design/i) }); + }); + + it('keeps missing asset uses and unsupported mappings machine-readable', async () => { + const plan: AuthoringPlan = { + version: 1, generatorVersion: '0.9.0', target: { name: 'example/diagnostic-asset', title: 'Asset' }, + source: { entry: '/tmp/diagnostic/design.html', sha256: '0'.repeat(64), format: 'html' }, + coverage: { styles: [], assets: [{ reference: 'image.png', kind: 'image', outcome: 'prepared', sha256: '1'.repeat(64), destination: 'assets/image.png', source: { path: '/tmp/diagnostic/design.html', offset: 4 } }] }, + structure: [], fields: [], locking: { mode: 'none' }, styles: { strategy: 'native', outcomes: [] }, pattern: { ready: false, overrides: [] }, files: [], warnings: [], + assets: [{ id: 'image', source: '/tmp/diagnostic/image.png', destination: 'assets/image.png', sha256: '1'.repeat(64), uses: [] }], + }; + try { + validateCoverageFulfillment(plan, '', '/tmp/diagnostic'); + throw new Error('expected missing asset use'); + } catch (error) { + expect(error).toBeInstanceOf(AuthorDiagnosticError); + expect((error as AuthorDiagnosticError).diagnostics[0]).toMatchObject({ code: 'coverage-missing-asset-use', source: { offset: 4 }, details: { reference: 'image.png', action: expect.any(String) } }); + } + + const html = ''; + const report = await author(html, { + author: { name: 'example/diagnostic-unsupported', styles: { mode: 'css', css: 'div .move:hover { color: red; }' } }, + proposal: { structure: [{ id: 'group', block: 'core/group', sourceRef: sourceRef(html, 'div') }, { id: 'button', block: 'core/button', sourceRef: sourceRef(html, 'a') }] }, + }); + expect(report.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'unresolved-native-style-mapping', details: expect.objectContaining({ classification: 'unsupported-native-style-mapping', unsupportedReason: expect.any(String) }) }), + ])); + }); +}); diff --git a/test/author.proposal.test.ts b/test/author.proposal.test.ts index 55afd64..7fe0c3f 100644 --- a/test/author.proposal.test.ts +++ b/test/author.proposal.test.ts @@ -79,7 +79,10 @@ describe('author proposal boundary', () => { const reason = report.items.map((item) => item.reason).join('\n'); expect(reason).toContain('proposal sourceRef'); expect(reason).toMatch(/stale|another source/i); - expect(reason).toContain('/design/multiline.html:2:3 (offset 12)'); + expect(reason).not.toContain('(offset 12)'); + expect(report.items[0]).toMatchObject({ code: 'stale-proposal-source-ref', source: { path: '/design/multiline.html' }, details: { sourceRef: foreignRef, referenceVerified: false } }); + expect(report.items[0]!.source).not.toHaveProperty('offset'); + expect(report.items[0]!.source).not.toHaveProperty('htmlLine'); }); it('locates a source-decision stale reference from a foreign source hash', async () => { @@ -95,7 +98,10 @@ describe('author proposal boundary', () => { const reason = report.items.map((item) => item.reason).join('\n'); expect(reason).toContain('proposal decision sourceRef'); expect(reason).toMatch(/stale|another source/i); - expect(reason).toContain('/design/multiline.html:2:3 (offset 12)'); + expect(reason).not.toContain('(offset 12)'); + expect(report.items[0]).toMatchObject({ code: 'stale-proposal-source-ref', source: { path: '/design/multiline.html' }, details: { sourceRef: foreignRef, referenceVerified: false } }); + expect(report.items[0]!.source).not.toHaveProperty('offset'); + expect(report.items[0]!.source).not.toHaveProperty('htmlLine'); }); it('carries proposal-owned editing decisions and fails a silent omitted source element', async () => {