@shapeshift-labs/frontier-lang-css 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -211,7 +211,8 @@ Package source repositories:
211
211
  ```js
212
212
  import {
213
213
  createCssSemanticMergeEvidence,
214
- emitCssWithSourceMap
214
+ emitCssWithSourceMap,
215
+ safeMergeCssSource
215
216
  } from '@shapeshift-labs/frontier-lang-css';
216
217
 
217
218
  const { code, sourceMap } = emitCssWithSourceMap(document, {
@@ -222,12 +223,20 @@ const { code, sourceMap } = emitCssWithSourceMap(document, {
222
223
  const evidence = createCssSemanticMergeEvidence(code, {
223
224
  sourcePath: 'todo.css'
224
225
  });
226
+
227
+ const merge = safeMergeCssSource({
228
+ sourcePath: 'button.css',
229
+ baseSourceText: '.button { color: red; }\n',
230
+ workerSourceText: '.button { color: blue; }\n',
231
+ headSourceText: '.button { color: red; background-color: white; }\n'
232
+ });
225
233
  ```
226
234
 
227
- `sourceMap.mappings` links emitted rule blocks back to Frontier Lang semantic node ids. `createCssSemanticMergeEvidence` records selectors, specificity, declarations, custom properties, cascade keys, source spans, stable hashes, and fail-closed proof gaps for cascade/render-sensitive CSS surfaces.
235
+ `sourceMap.mappings` links emitted rule blocks back to Frontier Lang semantic node ids. `createCssSemanticMergeEvidence` records selectors, specificity, declarations, custom properties, cascade keys, source spans, stable hashes, and fail-closed proof gaps for cascade/render-sensitive CSS surfaces. `safeMergeCssSource` admits independent unscoped declaration edits by cascade key and blocks overlapping declaration changes or proof gaps.
228
236
 
229
237
  ## Support Boundary
230
238
 
231
239
  - Ready evidence: style rules, selectors, specificity, declarations, custom properties, source spans, stable hashes.
240
+ - Safe merge: independent unscoped declarations with non-overlapping cascade keys; output is a canonical CSS render and not a byte/trivia-preserving claim.
232
241
  - Review-only gaps: shorthands without longhand expansion, scoped cascade under `@media` / `@supports` / `@container` / `@layer`, `@keyframes`, `@font-face`, `@page`, browser layout and render equivalence.
233
242
  - Claims: `autoMergeClaim`, `semanticEquivalenceClaim`, `browserCascadeEquivalenceClaim`, and `browserRenderEquivalenceClaim` remain false.
package/dist/index.d.ts CHANGED
@@ -217,6 +217,48 @@ export interface CssSemanticMergeEvidence {
217
217
  readonly browserRenderEquivalenceClaim: false;
218
218
  }
219
219
 
220
+ export interface CssSafeMergeConflict {
221
+ readonly code: string;
222
+ readonly gateId: 'css-semantic-merge' | string;
223
+ readonly sourcePath?: string;
224
+ readonly details: Readonly<Record<string, unknown>> & { readonly reasonCode: string; readonly conflictKey: string };
225
+ }
226
+
227
+ export interface CssSafeMergeAdmission {
228
+ readonly status: 'auto-merge-candidate' | 'blocked' | string;
229
+ readonly action: 'apply-css' | 'human-review' | string;
230
+ readonly reviewRequired: boolean;
231
+ readonly reasonCodes: readonly string[];
232
+ }
233
+
234
+ export interface CssSafeMergeResult {
235
+ readonly kind: 'frontier.lang.cssSafeMerge';
236
+ readonly version: 1;
237
+ readonly id: string;
238
+ readonly sourcePath?: string;
239
+ readonly status: 'merged' | 'blocked' | string;
240
+ readonly operation: string;
241
+ readonly mergedSourceText?: string;
242
+ readonly mergedSourceHash?: string;
243
+ readonly conflicts: readonly CssSafeMergeConflict[];
244
+ readonly admission: CssSafeMergeAdmission;
245
+ readonly autoMergeClaim: false;
246
+ readonly semanticEquivalenceClaim: false;
247
+ readonly baseSheetHash?: string;
248
+ readonly workerSheetHash?: string;
249
+ readonly headSheetHash?: string;
250
+ readonly workerChangedDeclarations?: number;
251
+ readonly headChangedDeclarations?: number;
252
+ }
253
+
254
+ export interface CssSafeMergeInput {
255
+ readonly id?: string;
256
+ readonly sourcePath?: string;
257
+ readonly baseSourceText?: string;
258
+ readonly workerSourceText?: string;
259
+ readonly headSourceText?: string;
260
+ }
261
+
220
262
  export declare function toCssAst(document: FrontierLangDocument, options?: CssProjectionOptions): CssAstStylesheet;
221
263
  export declare function renderCssAst(ast: CssAstStylesheet): string;
222
264
  export declare function renderCssAstWithSourceMap(ast: CssAstStylesheet, options?: CssProjectionOptions): CssProjectionResult;
@@ -224,3 +266,4 @@ export declare function emitCss(document: FrontierLangDocument, options?: CssPro
224
266
  export declare function emitCssWithSourceMap(document: FrontierLangDocument, options?: CssProjectionOptions): CssProjectionWithAstResult;
225
267
  export declare function parseCssSemanticSheet(sourceText: string, options?: CssProjectionOptions): CssSemanticSheet;
226
268
  export declare function createCssSemanticMergeEvidence(sourceText: string, options?: CssProjectionOptions): CssSemanticMergeEvidence;
269
+ export declare function safeMergeCssSource(input?: CssSafeMergeInput): CssSafeMergeResult;
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { hashSemanticValue } from '@shapeshift-labs/frontier-lang-kernel';
2
2
  import { createCssModuleEvidence } from './css-modules.js';
3
+ import { safeMergeCssSource as safeMergeCssSourceImpl } from './semantic-merge.js';
3
4
 
4
5
  const ShorthandProperties = new Set(['all', 'animation', 'background', 'border', 'border-block', 'border-color', 'border-image', 'border-inline', 'border-radius', 'border-style', 'border-width', 'columns', 'flex', 'font', 'gap', 'grid', 'grid-area', 'grid-column', 'grid-row', 'inset', 'list-style', 'margin', 'offset', 'outline', 'overflow', 'padding', 'place-content', 'place-items', 'place-self', 'text-decoration', 'transition']);
5
6
  const RuntimeAtRules = new Set(['keyframes', 'font-face', 'page', 'property']);
@@ -107,6 +108,10 @@ export function createCssSemanticMergeEvidence(sourceText, options = {}) {
107
108
  };
108
109
  }
109
110
 
111
+ export function safeMergeCssSource(input = {}) {
112
+ return safeMergeCssSourceImpl(input, { parseCssSemanticSheet, hashSemanticValue });
113
+ }
114
+
110
115
  function parseCssBlocks(sourceText, start, end, scopes, lineStarts, sourceHash) {
111
116
  const records = [];
112
117
  let index = start;
@@ -0,0 +1,173 @@
1
+ function safeMergeCssSource(input = {}, context = {}) {
2
+ const parseSheet = context.parseCssSemanticSheet;
3
+ const hash = context.hashSemanticValue;
4
+ const id = String(input.id ?? 'css_safe_merge');
5
+ const sourcePath = input.sourcePath;
6
+ const base = input.baseSourceText;
7
+ const worker = input.workerSourceText ?? base;
8
+ const head = input.headSourceText ?? base;
9
+ if (typeof base !== 'string' || typeof worker !== 'string' || typeof head !== 'string') return blocked(id, sourcePath, 'css-source-text-missing');
10
+ if (worker === head) return merged(id, sourcePath, worker, 'worker-head-identical', hash);
11
+ if (worker === base) return merged(id, sourcePath, head, 'worker-unchanged', hash);
12
+ if (head === base) return merged(id, sourcePath, worker, 'head-unchanged', hash);
13
+ const sheets = {
14
+ base: parseSheet(base, { sourcePath }),
15
+ worker: parseSheet(worker, { sourcePath }),
16
+ head: parseSheet(head, { sourcePath })
17
+ };
18
+ const indexes = Object.fromEntries(Object.entries(sheets).map(([name, sheet]) => [name, declarationIndex(sheet)]));
19
+ const changed = {
20
+ worker: changedDeclarations(indexes.base, indexes.worker, 'worker'),
21
+ head: changedDeclarations(indexes.base, indexes.head, 'head')
22
+ };
23
+ const proofConflicts = proofGapConflicts(id, sourcePath, changed, indexes);
24
+ const overlapConflicts = overlapDeclarationConflicts(id, sourcePath, changed.worker, changed.head);
25
+ const conflicts = [...proofConflicts, ...overlapConflicts];
26
+ if (conflicts.length) return blocked(id, sourcePath, 'css-semantic-merge-conflict', conflicts);
27
+ const mergedIndex = applyDeclarationChanges(applyDeclarationChanges(indexes.base, changed.head), changed.worker);
28
+ return merged(id, sourcePath, renderDeclarationIndex(mergedIndex), 'semantic-declaration-merge', hash, {
29
+ baseSheetHash: sheets.base.sheetHash,
30
+ workerSheetHash: sheets.worker.sheetHash,
31
+ headSheetHash: sheets.head.sheetHash,
32
+ workerChangedDeclarations: changed.worker.length,
33
+ headChangedDeclarations: changed.head.length
34
+ });
35
+ }
36
+
37
+ function declarationIndex(sheet) {
38
+ const declarations = new Map();
39
+ const order = [];
40
+ for (const record of sheet.records) {
41
+ if (record.kind !== 'rule') continue;
42
+ const ruleKey = ruleIdentityKey(record);
43
+ for (const declaration of record.declarations ?? []) {
44
+ const entry = {
45
+ key: declaration.cascadeKey,
46
+ ruleKey,
47
+ selectors: record.selectors,
48
+ scopes: record.scopes ?? [],
49
+ property: declaration.property,
50
+ value: declaration.value,
51
+ important: declaration.important,
52
+ declarationHash: declaration.declarationHash,
53
+ proofGaps: proofGapsForDeclaration(record, declaration)
54
+ };
55
+ declarations.set(entry.key, entry);
56
+ order.push(entry.key);
57
+ }
58
+ }
59
+ return { declarations, order: unique(order) };
60
+ }
61
+
62
+ function changedDeclarations(baseIndex, currentIndex, side) {
63
+ const keys = unique([...baseIndex.declarations.keys(), ...currentIndex.declarations.keys()]);
64
+ return keys.flatMap((key) => {
65
+ const before = baseIndex.declarations.get(key);
66
+ const after = currentIndex.declarations.get(key);
67
+ if ((before?.declarationHash ?? '') === (after?.declarationHash ?? '')) return [];
68
+ return [{ side, key, before, after, kind: before && after ? 'update' : before ? 'delete' : 'add' }];
69
+ });
70
+ }
71
+
72
+ function proofGapConflicts(id, sourcePath, changed, indexes) {
73
+ const changedKeys = new Set([...changed.worker, ...changed.head].map((change) => change.key));
74
+ return [...changedKeys].flatMap((key) => {
75
+ const entry = indexes.worker.declarations.get(key) ?? indexes.head.declarations.get(key);
76
+ return (entry?.proofGaps ?? []).map((gap) => conflict(id, sourcePath, 'css-proof-gap-blocked', gap.code, {
77
+ cascadeKey: key,
78
+ proofGap: gap
79
+ }));
80
+ });
81
+ }
82
+
83
+ function overlapDeclarationConflicts(id, sourcePath, workerChanges, headChanges) {
84
+ const headByKey = new Map(headChanges.map((change) => [change.key, change]));
85
+ return workerChanges.flatMap((workerChange) => {
86
+ const headChange = headByKey.get(workerChange.key);
87
+ if (!headChange || sameChange(workerChange, headChange)) return [];
88
+ return [conflict(id, sourcePath, 'css-cascade-declaration-conflict', 'css-cascade-declaration-conflict', {
89
+ cascadeKey: workerChange.key,
90
+ worker: changeDetails(workerChange),
91
+ head: changeDetails(headChange)
92
+ })];
93
+ });
94
+ }
95
+
96
+ function applyDeclarationChanges(index, changes) {
97
+ const declarations = new Map(index.declarations);
98
+ const order = [...index.order];
99
+ for (const change of changes) {
100
+ if (!change.after) declarations.delete(change.key);
101
+ else {
102
+ declarations.set(change.key, change.after);
103
+ if (!order.includes(change.key)) order.push(change.key);
104
+ }
105
+ }
106
+ return { declarations, order: order.filter((key) => declarations.has(key)) };
107
+ }
108
+
109
+ function renderDeclarationIndex(index) {
110
+ const groups = new Map();
111
+ for (const key of index.order) {
112
+ const declaration = index.declarations.get(key);
113
+ if (!declaration || declaration.scopes.length) continue;
114
+ groups.set(declaration.ruleKey, [...(groups.get(declaration.ruleKey) ?? []), declaration]);
115
+ }
116
+ const chunks = [];
117
+ for (const declarations of groups.values()) {
118
+ chunks.push(`${declarations[0].selectors.join(', ')} {`);
119
+ for (const declaration of declarations) chunks.push(` ${declaration.property}: ${declaration.value};`);
120
+ chunks.push('}', '');
121
+ }
122
+ return `${chunks.join('\n').trimEnd()}\n`;
123
+ }
124
+
125
+ function merged(id, sourcePath, sourceText, operation, hash, extra = {}) {
126
+ return result(id, sourcePath, 'merged', {
127
+ operation,
128
+ mergedSourceText: sourceText,
129
+ mergedSourceHash: hash?.(sourceText),
130
+ conflicts: [],
131
+ ...extra
132
+ });
133
+ }
134
+
135
+ function blocked(id, sourcePath, reasonCode, conflicts = []) {
136
+ return result(id, sourcePath, 'blocked', {
137
+ operation: 'blocked',
138
+ conflicts: conflicts.length ? conflicts : [conflict(id, sourcePath, reasonCode, reasonCode)]
139
+ });
140
+ }
141
+
142
+ function result(id, sourcePath, status, body) {
143
+ return {
144
+ kind: 'frontier.lang.cssSafeMerge',
145
+ version: 1,
146
+ id,
147
+ sourcePath,
148
+ status,
149
+ autoMergeClaim: false,
150
+ semanticEquivalenceClaim: false,
151
+ ...body,
152
+ admission: {
153
+ status: status === 'merged' ? 'auto-merge-candidate' : 'blocked',
154
+ action: status === 'merged' ? 'apply-css' : 'human-review',
155
+ reviewRequired: status !== 'merged',
156
+ reasonCodes: unique((body.conflicts ?? []).map((item) => item.details.reasonCode))
157
+ }
158
+ };
159
+ }
160
+
161
+ function conflict(id, sourcePath, code, reasonCode, details = {}) {
162
+ return { code, gateId: 'css-semantic-merge', sourcePath, details: { reasonCode, conflictKey: `css#${id}#${reasonCode}#${details.cascadeKey ?? sourcePath ?? 'source'}`, ...details } };
163
+ }
164
+
165
+ function sameChange(left, right) { return (left.after?.declarationHash ?? '') === (right.after?.declarationHash ?? '') && left.kind === right.kind; }
166
+ function changeDetails(change) { return { kind: change.kind, property: change.after?.property ?? change.before?.property, value: change.after?.value, beforeValue: change.before?.value }; }
167
+ function ruleIdentityKey(record) { return [...(record.scopes ?? []), record.selectors.join(',')].join('::'); }
168
+ function proofGapsForDeclaration(record, declaration) {
169
+ return (record.proofGaps ?? []).filter((gap) => gap.code !== 'css-shorthand-expansion-unproved' || gap.summary.includes(` ${declaration.property} `));
170
+ }
171
+ function unique(values) { return [...new Set(values.filter(Boolean))]; }
172
+
173
+ export { safeMergeCssSource };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shapeshift-labs/frontier-lang-css",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "CSS semantic merge evidence and projection adapter for Frontier Lang semantic source documents.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",