Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 32 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,33 +1,46 @@
# Structured Merge TypeScript
# StructuredMerge TypeScript

Monorepo for the TypeScript implementation of the Structured Merge library
family.
TypeScript implementation of the StructuredMerge contract.

Initial workspace packages:
This repository is one of four peer launch implementations: [Go](https://github.com/structuredmerge/structuredmerge-go), [TypeScript](https://github.com/structuredmerge/structuredmerge-typescript), [Rust](https://github.com/structuredmerge/structuredmerge-rust), and [Ruby](https://github.com/structuredmerge/structuredmerge-ruby). The language repos are not separate products. They consume the same public spec and shared fixture corpus so tools can choose the runtime surface that fits their environment.

Project links:

- Website: <https://structuredmerge.org>
- Implementations overview: <https://structuredmerge.org/implementations.html>
- Conformance model: <https://structuredmerge.org/conformance.html>
- Specification: <https://github.com/structuredmerge/structuredmerge-spec>
- Shared fixtures: <https://github.com/structuredmerge/structuredmerge-fixtures>

## Workspace

This is a pnpm workspace for StructuredMerge packages.

Initial packages:

- `@structuredmerge/tree-haver`
- `@structuredmerge/ast-merge`
- `@structuredmerge/text-merge`
- `@structuredmerge/json-merge`

## Conformance

Integration tests should consume the shared fixture corpus from the sibling `../structuredmerge-fixtures` checkout. A ruleset, fixture, diagnostic shape, or review outcome should mean the same thing whether exercised through Go, TypeScript, Rust, or Ruby.

Use the spec repository's conformance matrix for the current launch-readiness snapshot:

- <https://github.com/structuredmerge/structuredmerge-spec/blob/main/conformance-matrix.md>
- <https://github.com/structuredmerge/structuredmerge-spec/blob/main/IMPLEMENTATION_STATUS.md>

## Development

Standard repo tasks are exposed through `mise` and `pnpm`:
Standard repo tasks are exposed through `mise` and native TypeScript tooling.

- `mise run install`
- `mise run format`
- `mise run format-check`
- `mise run lint`
- `mise run typecheck`
- `mise run test`
- `mise run check`
Common checks:

The TypeScript monorepo uses:
- `mise run check`
- `pnpm test`

- `eslint` for linting
- `prettier` for formatting
- `tsc` for type checking
- `vitest` for unit tests
## Status

Integration tests consume the shared fixture corpus from the sibling
`../fixtures` repository rather than copying fixture data into this monorepo.
Early implementation work. Public compatibility claims should be tied to shared fixtures and documented conformance status rather than runtime-specific assumptions.
160 changes: 158 additions & 2 deletions packages/ast-merge/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export type DiagnosticCategory =
| 'ambiguity'
| 'assumed_default'
| 'configuration_error'
| 'replay_rejected';
| 'replay_rejected'
| ReviewTransportImportErrorCategory;

export type ReviewDiagnosticReason =
| 'missing_required_payload'
Expand Down Expand Up @@ -103,6 +104,162 @@ export interface ParseResult<TAnalysis> {
readonly policies?: readonly PolicyReference[];
}

export interface CompactRulesetDirective {
readonly name: string;
readonly arguments: readonly string[];
readonly line: number;
}

export interface CompactRuleset {
readonly directives: readonly CompactRulesetDirective[];
readonly comments: readonly string[];
}

const compactRulesetIdentifierPattern = /^[A-Za-z][A-Za-z0-9_.-]*$/;
const compactRulesetTokenPattern = /^[\x21\x24-\x7e]+$/;
const compactRulesetRequiredDirectives = ['format', 'owners', 'match', 'read', 'attach'] as const;
const compactRulesetSingletonDirectives = new Set([
'format',
'owners',
'match',
'read',
'attach',
'comment_style',
'render'
]);
const compactRulesetRepeatableKeyedDirectives = new Set([
'capability',
'logical_owner',
'repair',
'surface',
'delegate'
]);
const compactRulesetReadValues = new Set([
'source_augmented_portable_write',
'native_read_portable_write',
'native_mutation'
]);
const compactRulesetAttachValues = new Set([
'layout_only',
'tracker_layout_merge',
'augmenter_preferred_tracker_layout',
'normalize_tracked_layout_merge'
]);

export function parseCompactRuleset(source: string): ParseResult<CompactRuleset> {
const ruleset: CompactRuleset = { directives: [], comments: [] };
const directives: CompactRulesetDirective[] = [];
const comments: string[] = [];
const diagnostics: Diagnostic[] = [];
const seenDirectives = new Map<string, number>();
const seenRepeatableKeys = new Set<string>();

source.split('\n').forEach((rawLine, index) => {
const lineNumber = index + 1;
const line = rawLine.trim();
if (line.length === 0) return;
if (line.startsWith('#')) {
comments.push(line);
return;
}

const [name, ...args] = line.split(/\s+/);
const path = String(lineNumber);
if (!compactRulesetIdentifierPattern.test(name)) {
diagnostics.push(
compactRulesetDiagnostic(`invalid directive token ${JSON.stringify(name)}`, path)
);
return;
}
if (!compactRulesetKnownDirective(name)) {
diagnostics.push(compactRulesetDiagnostic(`unknown directive ${JSON.stringify(name)}`, path));
return;
}
if (args.length === 0) {
diagnostics.push(
compactRulesetDiagnostic(
`directive ${JSON.stringify(name)} requires at least one argument`,
path
)
);
return;
}
for (const arg of args) {
if (
arg !== 'true' &&
arg !== 'false' &&
!compactRulesetIdentifierPattern.test(arg) &&
!compactRulesetTokenPattern.test(arg)
) {
diagnostics.push(
compactRulesetDiagnostic(`invalid argument token ${JSON.stringify(arg)}`, path)
);
}
}

if (compactRulesetSingletonDirectives.has(name) && seenDirectives.has(name)) {
diagnostics.push(
compactRulesetDiagnostic(
`repeated singleton directive ${JSON.stringify(name)} first seen on line ${seenDirectives.get(name)}`,
path
)
);
}
if (compactRulesetRepeatableKeyedDirectives.has(name)) {
const key = `${name}\u0000${args[0]}`;
if (seenRepeatableKeys.has(key)) {
diagnostics.push(
compactRulesetDiagnostic(
`repeated ${JSON.stringify(name)} key ${JSON.stringify(args[0])}`,
path
)
);
}
seenRepeatableKeys.add(key);
}
if (name === 'read' && !compactRulesetReadValues.has(args[0])) {
diagnostics.push(
compactRulesetDiagnostic(`unknown read value ${JSON.stringify(args[0])}`, path)
);
}
if (name === 'attach' && !compactRulesetAttachValues.has(args[0])) {
diagnostics.push(
compactRulesetDiagnostic(`unknown attach value ${JSON.stringify(args[0])}`, path)
);
}

seenDirectives.set(name, lineNumber);
directives.push({ name, arguments: args, line: lineNumber });
});

for (const required of compactRulesetRequiredDirectives) {
if (!seenDirectives.has(required)) {
diagnostics.push(
compactRulesetDiagnostic(`missing required directive ${JSON.stringify(required)}`)
);
}
}

return diagnostics.length === 0
? { ok: true, diagnostics: [], analysis: { ...ruleset, directives, comments }, policies: [] }
: { ok: false, diagnostics, policies: [] };
}

function compactRulesetKnownDirective(name: string): boolean {
return (
compactRulesetSingletonDirectives.has(name) || compactRulesetRepeatableKeyedDirectives.has(name)
);
}

function compactRulesetDiagnostic(message: string, path?: string): Diagnostic {
return {
severity: 'error',
category: 'configuration_error',
message,
path
};
}

export interface MergeResult<TOutput> {
readonly ok: boolean;
readonly diagnostics: readonly Diagnostic[];
Expand Down Expand Up @@ -5201,7 +5358,6 @@ export function applyTemplateTreeExecutionToDirectory(
}

export function reportTemplateTreeRun(result: TemplateTreeRunResult): TemplateTreeRunReport {
const created = new Set(result.applyResult.createdPaths);
const updated = new Set(result.applyResult.updatedPaths);
const kept = new Set(result.applyResult.keptPaths);
const blocked = new Set(result.applyResult.blockedPaths);
Expand Down
3 changes: 3 additions & 0 deletions packages/ast-merge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export type {
Diagnostic,
DiagnosticCategory,
DiagnosticSeverity,
CompactRuleset,
CompactRulesetDirective,
DiscoveredSurface,
DelegatedChildOperation,
ReviewDiagnosticReason,
Expand Down Expand Up @@ -244,6 +246,7 @@ export {
STRUCTURED_EDIT_TRANSPORT_VERSION,
REVIEW_TRANSPORT_VERSION,
conformanceManifestReplayContext,
parseCompactRuleset,
conformanceManifestReviewStateEnvelope,
conformanceManifestReviewRequestIds,
conformanceReviewHostHints,
Expand Down
43 changes: 43 additions & 0 deletions packages/ast-merge/test/compact-ruleset.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { parseCompactRuleset } from '../src/index';

function rulesetFixtures(root: string): string[] {
return readdirSync(root).flatMap((entry) => {
const child = path.join(root, entry);
if (statSync(child).isDirectory()) return rulesetFixtures(child);
return child.endsWith('.smrules') ? [child] : [];
});
}

describe('parseCompactRuleset', () => {
it('parses shared compact ruleset fixtures', () => {
const root = path.resolve(import.meta.dirname, '..', '..', '..', '..', 'fixtures', 'rulesets');
const fixtures = rulesetFixtures(root);
expect(fixtures.length).toBeGreaterThan(0);
for (const fixture of fixtures) {
const result = parseCompactRuleset(readFileSync(fixture, 'utf8'));
expect(result.ok, `${fixture}: ${JSON.stringify(result.diagnostics)}`).toBe(true);
expect(result.analysis?.directives.length).toBeGreaterThan(0);
}
});

it('rejects malformed compact ruleset edges', () => {
const cases = {
'missing-required':
'format json\nowners line_bound_statements\nmatch stable_path\nread native_read_portable_write\n',
'repeated-format':
'format json\nformat yaml\nowners line_bound_statements\nmatch stable_path\nread native_read_portable_write\nattach layout_only\n',
'unknown-read':
'format json\nowners line_bound_statements\nmatch stable_path\nread imaginary\nattach layout_only\n',
'unknown-directive':
'format json\nowners line_bound_statements\nmatch stable_path\nread native_read_portable_write\nattach layout_only\nmystery value\n'
};
for (const [name, source] of Object.entries(cases)) {
const result = parseCompactRuleset(source);
expect(result.ok, name).toBe(false);
expect(result.diagnostics.length, name).toBeGreaterThan(0);
}
});
});
7 changes: 6 additions & 1 deletion packages/ast-merge/test/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,12 @@ describe('executeNestedMerge', () => {
] as const;

const runs = executeReviewedNestedExecutions(executions, (execution) => ({
mergeParent: () => ({ ok: true, diagnostics: [], output: `${execution.family}-merged`, policies: [] }),
mergeParent: () => ({
ok: true,
diagnostics: [],
output: `${execution.family}-merged`,
policies: []
}),
discoverOperations: () => ({
ok: true,
diagnostics: [],
Expand Down
Loading