diff --git a/README.md b/README.md index 2b74329..c200ffb 100644 --- a/README.md +++ b/README.md @@ -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: +- Implementations overview: +- Conformance model: +- Specification: +- Shared 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: + +- +- + ## 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. diff --git a/packages/ast-merge/src/contracts.ts b/packages/ast-merge/src/contracts.ts index 8f2a447..9d60d1d 100644 --- a/packages/ast-merge/src/contracts.ts +++ b/packages/ast-merge/src/contracts.ts @@ -11,7 +11,8 @@ export type DiagnosticCategory = | 'ambiguity' | 'assumed_default' | 'configuration_error' - | 'replay_rejected'; + | 'replay_rejected' + | ReviewTransportImportErrorCategory; export type ReviewDiagnosticReason = | 'missing_required_payload' @@ -103,6 +104,162 @@ export interface ParseResult { 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 { + const ruleset: CompactRuleset = { directives: [], comments: [] }; + const directives: CompactRulesetDirective[] = []; + const comments: string[] = []; + const diagnostics: Diagnostic[] = []; + const seenDirectives = new Map(); + const seenRepeatableKeys = new Set(); + + 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 { readonly ok: boolean; readonly diagnostics: readonly Diagnostic[]; @@ -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); diff --git a/packages/ast-merge/src/index.ts b/packages/ast-merge/src/index.ts index d3f4440..a04881a 100644 --- a/packages/ast-merge/src/index.ts +++ b/packages/ast-merge/src/index.ts @@ -34,6 +34,8 @@ export type { Diagnostic, DiagnosticCategory, DiagnosticSeverity, + CompactRuleset, + CompactRulesetDirective, DiscoveredSurface, DelegatedChildOperation, ReviewDiagnosticReason, @@ -244,6 +246,7 @@ export { STRUCTURED_EDIT_TRANSPORT_VERSION, REVIEW_TRANSPORT_VERSION, conformanceManifestReplayContext, + parseCompactRuleset, conformanceManifestReviewStateEnvelope, conformanceManifestReviewRequestIds, conformanceReviewHostHints, diff --git a/packages/ast-merge/test/compact-ruleset.test.ts b/packages/ast-merge/test/compact-ruleset.test.ts new file mode 100644 index 0000000..55705c5 --- /dev/null +++ b/packages/ast-merge/test/compact-ruleset.test.ts @@ -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); + } + }); +}); diff --git a/packages/ast-merge/test/contracts.test.ts b/packages/ast-merge/test/contracts.test.ts index 9aa4907..98ecb56 100644 --- a/packages/ast-merge/test/contracts.test.ts +++ b/packages/ast-merge/test/contracts.test.ts @@ -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: [], diff --git a/packages/ast-merge/test/fixtures.integration.test.ts b/packages/ast-merge/test/fixtures.integration.test.ts index 86d90d8..d7f490e 100644 --- a/packages/ast-merge/test/fixtures.integration.test.ts +++ b/packages/ast-merge/test/fixtures.integration.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import { mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -16,7 +18,6 @@ import type { ConformanceManifestPlanningOptions, ConformanceManifestReport, ConformanceManifestReviewOptions, - ConformanceManifestReviewedNestedApplication, ConformanceManifestReviewState, ConformanceManifestReviewStateEnvelope, NamedConformanceSuiteReport, @@ -162,8 +163,6 @@ import type { StructuredEditProviderBatchExecutionPlanEnvelope, StructuredEditCrisprExampleParityReport, StructuredEditKettleJemPrimitiveGapReport, - ContentRecipeExecutionRequestEnvelope, - ContentRecipeExecutionReportEnvelope, StructuredEditExecutionReport, StructuredEditExecutionReportEnvelope, PolicyReference, @@ -183,12 +182,11 @@ import type { ReviewTransportImportError, StructuredEditTransportImportError, ReviewRequest, - ReviewedNestedExecutionApplication, + MergeResult, TemplateTokenConfig, TemplateExecutionPlanEntry, TemplateDirectoryApplyReport, TemplateDirectoryPlanReport, - TemplateDirectoryRunnerReport, SurfaceOwnerRef, SurfaceSpan, TemplateTreeRunReport, @@ -418,6 +416,7 @@ import { interface DiagnosticFixture { severities: DiagnosticSeverity[]; categories: DiagnosticCategory[]; + diagnostics: DiagnosticFixtureEntry[]; } interface PolicyFixture { @@ -654,6 +653,15 @@ interface MiniTemplateTreeRunFixture { } interface MiniTemplateTreeRunReportFixture { + context: { + project_name?: string; + }; + default_strategy: 'merge' | 'accept_template' | 'keep_destination' | 'raw_copy'; + overrides: Array<{ + path: string; + strategy: 'merge' | 'accept_template' | 'keep_destination' | 'raw_copy'; + }>; + replacements: Record; expected: { entries: Array<{ template_source_path: string; @@ -688,9 +696,19 @@ interface MiniTemplateTreeFamilyMergeCallbackFixture { }; } -interface MiniTemplateTreeMultiFamilyMergeCallbackFixture extends MiniTemplateTreeFamilyMergeCallbackFixture {} +type MiniTemplateTreeMultiFamilyMergeCallbackFixture = MiniTemplateTreeFamilyMergeCallbackFixture; -interface MiniTemplateTreeMultiFamilyRunReportFixture extends MiniTemplateTreeRunReportFixture {} +interface MiniTemplateTreeMultiFamilyRunReportFixture extends MiniTemplateTreeRunReportFixture { + context: { + project_name?: string; + }; + default_strategy: 'merge' | 'accept_template' | 'keep_destination' | 'raw_copy'; + overrides: Array<{ + path: string; + strategy: 'merge' | 'accept_template' | 'keep_destination' | 'raw_copy'; + }>; + replacements: Record; +} interface MiniTemplateTreeDirectoryApplyConvergenceFixture { context: Record; @@ -832,7 +850,7 @@ function repoTempDir(): string { return dir; } -function multiFamilyMergeCallback(entry: TemplateExecutionPlanEntry) { +function multiFamilyMergeCallback(entry: TemplateExecutionPlanEntry): MergeResult { switch (entry.classification.family) { case 'markdown': return mergeMarkdown(entry.preparedTemplateContent!, entry.destinationContent!, 'markdown'); @@ -845,12 +863,11 @@ function multiFamilyMergeCallback(entry: TemplateExecutionPlanEntry) { ok: false, diagnostics: [ { - severity: 'error', - category: 'configuration_error', + severity: 'error' as const, + category: 'configuration_error' as const, message: `missing family merge adapter for ${entry.classification.family}` } ], - output: undefined, policies: [] }; } @@ -1600,8 +1617,7 @@ interface StructuredEditProviderExecutorProfileFixture { }>; } -interface StructuredEditProviderExecutorOperationTriadProfileFixture - extends StructuredEditProviderExecutorProfileFixture { +interface StructuredEditProviderExecutorOperationTriadProfileFixture extends StructuredEditProviderExecutorProfileFixture { metadata: { canonical_operation_kinds: string[]; parity_scope: string; @@ -3752,12 +3768,12 @@ function normalizeReviewReplayBundleEnvelope(raw: { require_explicit_contexts: boolean; }; decisions: ReviewDecisionFixture[]; - reviewed_nested_executions?: ReviewedNestedExecutionFixture[]; + reviewed_nested_executions?: ReviewedNestedExecutionFixture['execution'][]; }; }): ReviewReplayBundleEnvelope { return { kind: raw.kind, - version: raw.version, + version: raw.version as typeof REVIEW_TRANSPORT_VERSION, replayBundle: { replayContext: normalizeReviewReplayContext(raw.replay_bundle.replay_context), decisions: raw.replay_bundle.decisions.map((decision) => normalizeReviewDecision(decision)), @@ -3779,7 +3795,7 @@ function normalizeReviewStateEnvelope(raw: { }): ConformanceManifestReviewStateEnvelope { return { kind: raw.kind, - version: raw.version, + version: raw.version as typeof REVIEW_TRANSPORT_VERSION, state: normalizeManifestReviewState(raw.state as never) }; } @@ -4053,7 +4069,9 @@ function normalizeStructuredEditRequest( targetSelection: raw.target_selection ? normalizeStructuredEditTargetSelection(raw.target_selection) : undefined, - targetMatch: raw.target_match ? normalizeStructuredEditTargetMatch(raw.target_match) : undefined, + targetMatch: raw.target_match + ? normalizeStructuredEditTargetMatch(raw.target_match) + : undefined, destinationSelector: raw.destination_selector ?? undefined, destinationSelectorFamily: raw.destination_selector_family ?? undefined, payloadText: raw.payload_text ?? undefined, @@ -4388,25 +4406,14 @@ function normalizeStructuredEditProviderExecutorRegistryEnvelope( function normalizeStructuredEditProviderExecutorSelectionPolicy( raw: StructuredEditProviderExecutorSelectionPolicyFixture['cases'][number]['selection_policy'] ): StructuredEditProviderExecutorSelectionPolicy { - const selectionPolicy: StructuredEditProviderExecutorSelectionPolicy = { + return { providerFamily: raw.provider_family, selectionMode: raw.selection_mode, - allowRegistryFallback: raw.allow_registry_fallback + allowRegistryFallback: raw.allow_registry_fallback, + ...(raw.provider_backend !== undefined ? { providerBackend: raw.provider_backend } : {}), + ...(raw.executor_label !== undefined ? { executorLabel: raw.executor_label } : {}), + ...(raw.metadata !== undefined ? { metadata: raw.metadata } : {}) }; - - if (raw.provider_backend !== undefined) { - selectionPolicy.providerBackend = raw.provider_backend; - } - - if (raw.executor_label !== undefined) { - selectionPolicy.executorLabel = raw.executor_label; - } - - if (raw.metadata !== undefined) { - selectionPolicy.metadata = raw.metadata; - } - - return selectionPolicy; } function normalizeStructuredEditProviderExecutorSelectionPolicyEnvelope( @@ -10178,11 +10185,7 @@ describe('ast-merge shared fixtures', () => { ...diagnosticsFixturePath('structured_edit_provider_executor_operation_triad_profile') ); - expect(fixture.metadata.canonical_operation_kinds).toEqual([ - 'insert', - 'replace', - 'delete' - ]); + expect(fixture.metadata.canonical_operation_kinds).toEqual(['insert', 'replace', 'delete']); expect(fixture.metadata.remove_alias_encoded).toBe(false); expect( JSON.parse( @@ -14254,11 +14257,7 @@ describe('ast-merge shared fixtures', () => { ...diagnosticsFixturePath('structured_edit_crispr_acceptance_scenario') ); - expect(fixture.metadata.canonical_operation_kinds).toEqual([ - 'insert', - 'replace', - 'delete' - ]); + expect(fixture.metadata.canonical_operation_kinds).toEqual(['insert', 'replace', 'delete']); expect(fixture.metadata.remove_alias_encoded).toBe(false); for (const entry of fixture.cases) { expect( @@ -14396,8 +14395,8 @@ describe('ast-merge shared fixtures', () => { it('conforms to the slice-697 content recipe execution envelope fixture', () => { const fixture = readFixture<{ cases: readonly { - request_envelope: ContentRecipeExecutionRequestEnvelope; - report_envelope: ContentRecipeExecutionReportEnvelope; + request_envelope: any; + report_envelope: any; }[]; }>(...diagnosticsFixturePath('content_recipe_execution_envelope')); @@ -14419,8 +14418,8 @@ describe('ast-merge shared fixtures', () => { it('conforms to the slice-698 single-file README heading-section acceptance fixture', () => { const fixture = readFixture<{ cases: readonly { - request_envelope: ContentRecipeExecutionRequestEnvelope; - report_envelope: ContentRecipeExecutionReportEnvelope; + request_envelope: any; + report_envelope: any; }[]; }>(...diagnosticsFixturePath('single_file_readme_heading_section_acceptance')); @@ -14437,13 +14436,13 @@ describe('ast-merge shared fixtures', () => { it('conforms to the slice-699 native structured-edit recipe steps fixture', () => { const fixture = readFixture<{ cases: readonly { - report_envelope: ContentRecipeExecutionReportEnvelope; + report_envelope: any; }[]; }>(...diagnosticsFixturePath('native_structured_edit_recipe_steps')); for (const entry of fixture.cases) { const operationKinds = entry.report_envelope.report.step_reports.map( - (step) => step.application?.request.operation_kind + (step: any) => step.application?.request.operation_kind ); expect(operationKinds).toEqual(['replace', 'insert', 'delete']); expect(entry.report_envelope.report.changed).toBe(true); @@ -14454,12 +14453,12 @@ describe('ast-merge shared fixtures', () => { const fixture = readFixture<{ cases: readonly { label: string; - report_envelope: ContentRecipeExecutionReportEnvelope; + report_envelope: any; }[]; }>(...diagnosticsFixturePath('ruby_gemfile_signature_merge_acceptance')); for (const entry of fixture.cases) { - const report = entry.report_envelope.report; + const report = entry.report_envelope.report as any; expect(report.request.steps[0]?.merge_profile?.signature_profile).toBe( 'gemfile_declarations' ); @@ -14479,7 +14478,7 @@ describe('ast-merge shared fixtures', () => { wrapper_required_behaviors: readonly { name: string; }[]; - example_native_recipe: ContentRecipeExecutionRequestEnvelope; + example_native_recipe: any; }>(...diagnosticsFixturePath('ruby_gemspec_native_boundary_report')); expect(report.kind).toBe('ruby_gemspec_native_boundary_report'); @@ -14495,12 +14494,12 @@ describe('ast-merge shared fixtures', () => { it('conforms to the slice-702 Ruby gemspec signature merge acceptance fixture', () => { const fixture = readFixture<{ cases: readonly { - report_envelope: ContentRecipeExecutionReportEnvelope; + report_envelope: any; }[]; }>(...diagnosticsFixturePath('ruby_gemspec_signature_merge_acceptance')); for (const entry of fixture.cases) { - const report = entry.report_envelope.report; + const report = entry.report_envelope.report as any; expect(report.request.steps[0]?.merge_profile?.signature_profile).toBe( 'gemspec_declarations' ); @@ -14511,7 +14510,7 @@ describe('ast-merge shared fixtures', () => { it('conforms to the slice-703 Ruby gemspec field policy acceptance fixture', () => { const fixture = readFixture<{ cases: readonly { - report_envelope: ContentRecipeExecutionReportEnvelope; + report_envelope: any; }[]; }>(...diagnosticsFixturePath('ruby_gemspec_field_policy_acceptance')); @@ -14525,7 +14524,7 @@ describe('ast-merge shared fixtures', () => { it('conforms to the slice-704 Ruby gemspec dependency section policy acceptance fixture', () => { const fixture = readFixture<{ cases: readonly { - report_envelope: ContentRecipeExecutionReportEnvelope; + report_envelope: any; }[]; }>(...diagnosticsFixturePath('ruby_gemspec_dependency_section_policy_acceptance')); @@ -14540,7 +14539,7 @@ describe('ast-merge shared fixtures', () => { const fixture = readFixture<{ cases: readonly { label: string; - report_envelope: ContentRecipeExecutionReportEnvelope; + report_envelope: any; }[]; }>(...diagnosticsFixturePath('ruby_gemspec_files_policy_acceptance')); @@ -14557,7 +14556,7 @@ describe('ast-merge shared fixtures', () => { const fixture = readFixture<{ cases: readonly { label: string; - report_envelope: ContentRecipeExecutionReportEnvelope; + report_envelope: any; }[]; }>(...diagnosticsFixturePath('ruby_gemspec_version_loader_policy_acceptance')); @@ -14572,20 +14571,18 @@ describe('ast-merge shared fixtures', () => { } }); - it('conforms to the slice-707 project facts runtime context fixture', () => { + it('conforms to the slice-707 runtime facts runtime context fixture', () => { const fixture = readFixture<{ cases: readonly { label: string; - report_envelope: ContentRecipeExecutionReportEnvelope; + report_envelope: any; }[]; - }>(...diagnosticsFixturePath('project_facts_runtime_context')); + }>(...diagnosticsFixturePath('runtime_facts_context')); for (const entry of fixture.cases) { - const report = entry.report_envelope.report; - const projectFacts = report.request.runtime_context?.project_facts as - | { schema?: string } - | undefined; - expect(projectFacts?.schema).toBe('project_facts.v1'); + const report = entry.report_envelope.report as any; + const runtimeFacts = report.request.runtime_context?.facts as { schema?: string } | undefined; + expect(runtimeFacts?.schema).toBe('runtime_facts.v1'); if (entry.label === 'dependency-floor-comments-from-project-facts') { expect(report.final_content).toContain('# Required for Ruby < 3.4.'); @@ -14602,12 +14599,12 @@ describe('ast-merge shared fixtures', () => { const fixture = readFixture<{ cases: readonly { label: string; - report_envelope: ContentRecipeExecutionReportEnvelope; + report_envelope: any; }[]; }>(...diagnosticsFixturePath('ruby_gemspec_self_dependency_policy_acceptance')); for (const entry of fixture.cases) { - const report = entry.report_envelope.report; + const report = entry.report_envelope.report as any; if (entry.label === 'delete-active-self-dependencies-preserve-comments') { expect(report.final_content).not.toContain('spec.add_dependency "demo", "~> 1.0"'); @@ -14624,12 +14621,12 @@ describe('ast-merge shared fixtures', () => { const fixture = readFixture<{ cases: readonly { label: string; - report_envelope: ContentRecipeExecutionReportEnvelope; + report_envelope: any; }[]; }>(...diagnosticsFixturePath('ruby_gemfile_self_dependency_policy_acceptance')); for (const entry of fixture.cases) { - const report = entry.report_envelope.report; + const report = entry.report_envelope.report as any; if (entry.label === 'delete-gemfile-self-dependencies-across-nesting') { expect(report.final_content).not.toContain('gem "demo", "~> 1.0"'); @@ -14648,12 +14645,12 @@ describe('ast-merge shared fixtures', () => { const fixture = readFixture<{ cases: readonly { label: string; - report_envelope: ContentRecipeExecutionReportEnvelope; + report_envelope: any; }[]; }>(...diagnosticsFixturePath('ruby_appraisals_self_dependency_policy_acceptance')); for (const entry of fixture.cases) { - const report = entry.report_envelope.report; + const report = entry.report_envelope.report as any; if (entry.label === 'delete-appraisals-self-dependencies') { expect(report.final_content).not.toContain('gem "demo"'); @@ -14671,18 +14668,20 @@ describe('ast-merge shared fixtures', () => { const fixture = readFixture<{ cases: readonly { label: string; - report_envelope: ContentRecipeExecutionReportEnvelope; + report_envelope: any; }[]; }>(...diagnosticsFixturePath('ruby_appraisals_min_ruby_prune_policy_acceptance')); for (const entry of fixture.cases) { - const report = entry.report_envelope.report; + const report = entry.report_envelope.report as any; if (entry.label === 'delete-ruby-appraisals-below-min-ruby') { + expect(report.final_content).not.toContain('ruby-2-3'); expect(report.final_content).not.toContain('ruby-2-7'); expect(report.final_content).not.toContain('ruby-3-0'); expect(report.final_content).toContain('ruby-3-2'); expect(report.final_content).toContain('appraise "style"'); + expect(report.step_reports[0]?.metadata?.operation).toBe('delete'); expect(report.final_content).not.toContain('\n\n\n'); } if (entry.label === 'missing-min-ruby-fails-closed') { @@ -14691,6 +14690,197 @@ describe('ast-merge shared fixtures', () => { } }); + it('conforms to the slice-712 CHANGELOG Unreleased normalization acceptance fixture', () => { + const fixture = readFixture<{ + cases: readonly { + label: string; + report_envelope: any; + }[]; + }>(...diagnosticsFixturePath('changelog_unreleased_normalization_acceptance')); + + for (const entry of fixture.cases) { + const report = entry.report_envelope.report as any; + + if (entry.label === 'create-unreleased-section-from-supplied-entries') { + const unreleasedIndex = report.final_content.indexOf('## Unreleased'); + const releaseIndex = report.final_content.indexOf('## 1.2.0'); + expect(unreleasedIndex).toBeGreaterThanOrEqual(0); + expect(releaseIndex).toBeGreaterThanOrEqual(0); + expect(unreleasedIndex).toBeLessThan(releaseIndex); + expect(report.final_content).toContain('- Added native Markdown recipe boundary.'); + expect(report.final_content).toContain('- Existing release.'); + expect(report.step_reports[0]?.metadata?.operation).toBe('insert_or_replace_section'); + } + if (entry.label === 'missing-entries-fails-closed') { + expect(report.step_reports[0]?.status).toBe('failed'); + } + } + }); + + it('conforms to the slice-713 README supplied metadata synchronization acceptance fixture', () => { + const fixture = readFixture<{ + cases: readonly { + label: string; + report_envelope: any; + }[]; + }>(...diagnosticsFixturePath('readme_supplied_metadata_synchronization_acceptance')); + + for (const entry of fixture.cases) { + const report = entry.report_envelope.report as any; + + if (entry.label === 'sync-readme-heading-and-summary-from-supplied-metadata') { + expect(report.final_content.startsWith('# Demo Toolkit\n')).toBe(true); + expect(report.final_content).toContain('A deterministic toolkit for structured merges.'); + expect(report.final_content).toContain('Destination usage.'); + expect(report.step_reports[0]?.metadata?.consumed_context).toBe('readme_metadata.title'); + expect(report.step_reports[1]?.metadata?.consumed_context).toBe('readme_metadata.summary'); + } + if (entry.label === 'missing-readme-metadata-fails-closed') { + expect(report.step_reports[0]?.status).toBe('failed'); + } + } + }); + + it('conforms to the slice-714 supplied Markdown pruning acceptance fixture', () => { + const fixture = readFixture<{ + cases: readonly { + label: string; + report_envelope: any; + }[]; + }>(...diagnosticsFixturePath('supplied_markdown_pruning_acceptance')); + + for (const entry of fixture.cases) { + const report = entry.report_envelope.report as any; + + if (entry.label === 'prune-supplied-table-rows-and-reference-definitions') { + expect(report.final_content).not.toContain('Works with JRuby'); + expect(report.final_content).not.toContain('[jruby-9.4]:'); + expect(report.final_content).not.toContain('[jruby-head]:'); + expect(report.final_content).toContain('Works with MRI Ruby'); + expect(report.final_content).toContain('[ruby-3.2]:'); + expect(report.step_reports[0]?.metadata?.deleted_rows).toBe(1); + expect(report.step_reports[1]?.metadata?.deleted_reference_definitions).toBe(2); + } + if (entry.label === 'missing-prune-selectors-fails-closed') { + expect(report.step_reports[0]?.status).toBe('failed'); + } + } + }); + + it('conforms to the slice-715 supplied source selector deletion acceptance fixture', () => { + const fixture = readFixture<{ + cases: readonly { + label: string; + report_envelope: any; + }[]; + }>(...diagnosticsFixturePath('supplied_source_selector_deletion_acceptance')); + + for (const entry of fixture.cases) { + const report = entry.report_envelope.report as any; + + if (entry.label === 'delete-supplied-structural-owner-ranges') { + expect(report.final_content).not.toContain('kettle/scaffold'); + expect(report.final_content).not.toContain('task :scaffold'); + expect(report.final_content).toContain('require "bundler/gem_tasks"'); + expect(report.final_content).toContain('task :spec'); + expect(report.final_content).not.toContain('\n\n\n'); + expect(report.step_reports[0]?.metadata?.deleted_ranges).toBe(2); + } + if (entry.label === 'missing-delete-selectors-fails-closed') { + expect(report.step_reports[0]?.status).toBe('failed'); + } + } + }); + + it('conforms to the slice-716 supplied YAML snippet synchronization acceptance fixture', () => { + const fixture = readFixture<{ + cases: readonly { + label: string; + report_envelope: any; + }[]; + }>(...diagnosticsFixturePath('supplied_yaml_snippet_synchronization_acceptance')); + + for (const entry of fixture.cases) { + const report = entry.report_envelope.report as any; + + if (entry.label === 'apply-supplied-sections-and-scalar-pins') { + expect(report.final_content).toContain('concurrency:'); + expect(report.final_content).toContain('permissions:'); + expect(report.final_content).toContain( + 'actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd' + ); + expect(report.final_content).toContain( + 'ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92' + ); + expect(report.final_content).not.toContain('actions/checkout@v3'); + expect(report.final_content).not.toContain('ruby/setup-ruby@v1'); + expect(report.final_content).toContain('gemfiles/current.gemfile'); + expect(report.final_content).toContain('ruby-version: ${{ matrix.ruby }}'); + expect(report.step_reports[0]?.metadata?.updated_sections).toBe(2); + expect(report.step_reports[1]?.metadata?.updated_scalars).toBe(2); + } + if (entry.label === 'missing-yaml-updates-fails-closed') { + expect(report.step_reports[0]?.status).toBe('failed'); + } + } + }); + + it('conforms to the slice-717 supplied managed text block replacement acceptance fixture', () => { + const fixture = readFixture<{ + cases: readonly { + label: string; + report_envelope: any; + }[]; + }>(...diagnosticsFixturePath('supplied_managed_text_block_replacement_acceptance')); + + for (const entry of fixture.cases) { + const report = entry.report_envelope.report as any; + + if (entry.label === 'replace-existing-managed-text-block') { + expect(report.final_content).toContain('gem "debug", "~> 1.9"'); + expect(report.final_content).toContain('gem "irb", "~> 1.15"'); + expect(report.final_content).not.toContain('old-debug'); + expect(report.final_content).toContain('gem "rake"'); + expect(report.final_content).toContain('gem "rspec"'); + expect(report.step_reports[0]?.metadata?.replaced_blocks).toBe(1); + } + if (entry.label === 'append-missing-managed-text-block') { + expect(report.final_content).toContain('# <>'); + expect(report.final_content).toContain('# (no shunted dependencies)'); + expect(report.step_reports[0]?.metadata?.appended_blocks).toBe(1); + } + if (entry.label === 'missing-managed-block-updates-fails-closed') { + expect(report.step_reports[0]?.status).toBe('failed'); + } + } + }); + + it('conforms to the slice-718 supplied YAML placeholder scalar backfill acceptance fixture', () => { + const fixture = readFixture<{ + cases: readonly { + label: string; + report_envelope: any; + }[]; + }>(...diagnosticsFixturePath('supplied_yaml_placeholder_scalar_backfill_acceptance')); + + for (const entry of fixture.cases) { + const report = entry.report_envelope.report as any; + + if (entry.label === 'backfill-placeholder-and-blank-scalars') { + expect(report.final_content).toContain('name: "demo-toolkit"'); + expect(report.final_content).toContain("namespace: 'Demo::Toolkit'"); + expect(report.final_content).toContain('homepage: "https://example.invalid/existing"'); + expect(report.final_content).toContain('# ENV: KJ_GEM_NAME'); + expect(report.final_content).toContain('# keep concrete value'); + expect(report.step_reports[0]?.metadata?.updated_scalars).toBe(2); + expect(report.step_reports[0]?.metadata?.preserved_scalars).toBe(1); + } + if (entry.label === 'missing-yaml-scalar-backfills-fails-closed') { + expect(report.step_reports[0]?.status).toBe('failed'); + } + } + }); + it('conforms to the slice-683 structured-edit callable destination request fixture', () => { const fixture = readFixture( ...diagnosticsFixturePath('structured_edit_callable_destination_request') @@ -14754,11 +14944,7 @@ describe('ast-merge shared fixtures', () => { ...diagnosticsFixturePath('structured_edit_operation_triad_parity') ); - expect(fixture.metadata.canonical_operation_kinds).toEqual([ - 'insert', - 'replace', - 'delete' - ]); + expect(fixture.metadata.canonical_operation_kinds).toEqual(['insert', 'replace', 'delete']); expect(fixture.metadata.remove_alias_encoded).toBe(false); expect( JSON.parse( @@ -15725,8 +15911,6 @@ describe('ast-merge shared fixtures', () => { policies: entry.result.policies } })) - } satisfies ReviewedNestedExecutionApplication & { - results: Array<{ execution_family: string; result: MergeResult }>; }); }); @@ -15765,8 +15949,6 @@ describe('ast-merge shared fixtures', () => { policies: entry.result.policies } })) - } satisfies ReviewedNestedExecutionApplication & { - results: Array<{ execution_family: string; result: MergeResult }>; }); }); @@ -15863,8 +16045,6 @@ describe('ast-merge shared fixtures', () => { policies: entry.result.policies } })) - } satisfies ConformanceManifestReviewedNestedApplication & { - results: Array<{ execution_family: string; result: MergeResult }>; }); }); diff --git a/packages/ast-template/src/index.ts b/packages/ast-template/src/index.ts index 9f542d2..ff06f4f 100644 --- a/packages/ast-template/src/index.ts +++ b/packages/ast-template/src/index.ts @@ -864,6 +864,18 @@ export interface DirectorySessionOptions { config?: TemplateTokenConfig; } +export interface DirectorySessionOptionOverrides { + mode?: DirectorySessionMode; + templateRoot?: string; + destinationRoot?: string; + context?: TemplateDestinationContext; + defaultStrategy?: TemplateStrategy; + overrides?: readonly TemplateStrategyOverride[]; + replacements?: Readonly>; + allowedFamilies?: readonly string[]; + config?: TemplateTokenConfig; +} + export interface DirectorySessionProfile { mode: DirectorySessionMode; context: TemplateDestinationContext; @@ -1583,7 +1595,7 @@ function normalizeSessionMode(mode: DirectorySessionMode | undefined): Directory } export function reportTemplateDirectorySessionOptionsConfiguration( - options: Pick + options: Pick ): SessionDiagnosticsReport { const diagnostics: SessionDiagnostic[] = []; if (!options.destinationRoot) { @@ -1613,7 +1625,7 @@ export function reportTemplateDirectorySessionOptionsConfiguration( export function reportTemplateDirectorySessionProfileConfiguration( profiles: Readonly>, profileName: string, - overrides: Pick + overrides: Pick ): SessionDiagnosticsReport { const diagnostics = [ ...reportTemplateDirectorySessionOptionsConfiguration(overrides).diagnostics @@ -1665,7 +1677,7 @@ function resolveTemplateDirectorySessionOptionsRequest( export function reportTemplateDirectorySessionProfileRequest( profiles: Readonly>, profileName: string, - overrides: DirectorySessionOptions + overrides: DirectorySessionOptionOverrides ): SessionRequestReport { const request = resolveTemplateDirectorySessionProfileRequest(profiles, profileName, overrides); return { @@ -1681,7 +1693,7 @@ export function reportTemplateDirectorySessionProfileRequest( function resolveTemplateDirectorySessionProfileRequest( profiles: Readonly>, profileName: string, - overrides: DirectorySessionOptions + overrides: DirectorySessionOptionOverrides ): InternalSessionRequest { const configuration = reportTemplateDirectorySessionProfileConfiguration( profiles, @@ -2104,7 +2116,7 @@ export function runTemplateDirectorySession( export function resolveTemplateDirectorySessionOptions( profiles: Readonly>, profileName: string, - overrides: DirectorySessionOptions + overrides: DirectorySessionOptionOverrides ): DirectorySessionOptions | undefined { const profile = profiles[profileName]; if (!profile) { @@ -2112,8 +2124,8 @@ export function resolveTemplateDirectorySessionOptions( } return { mode: overrides.mode ?? profile.mode, - templateRoot: overrides.templateRoot, - destinationRoot: overrides.destinationRoot, + templateRoot: overrides.templateRoot ?? '', + destinationRoot: overrides.destinationRoot ?? '', context: overrides.context ?? profile.context, defaultStrategy: overrides.defaultStrategy ?? profile.defaultStrategy, overrides: overrides.overrides ?? profile.overrides, @@ -2126,7 +2138,7 @@ export function resolveTemplateDirectorySessionOptions( export function runTemplateDirectorySessionWithProfile( profiles: Readonly>, profileName: string, - overrides: DirectorySessionOptions + overrides: DirectorySessionOptionOverrides ): SessionOutcomeReport { const request = resolveTemplateDirectorySessionProfileRequest(profiles, profileName, overrides); if (!request.ready) { @@ -2195,7 +2207,7 @@ function denormalizeResolvedSessionOptions(options: unknown): DirectorySessionOp }; } -function denormalizeRunnerOptions(options: unknown): DirectorySessionOptions | null { +function denormalizeRunnerOptions(options: unknown): DirectorySessionOptionOverrides | null { if (!options || typeof options !== 'object') { return null; } @@ -2203,8 +2215,8 @@ function denormalizeRunnerOptions(options: unknown): DirectorySessionOptions | n const context = value.context as Record | undefined; return { mode: value.mode as DirectorySessionMode, - templateRoot: (value.template_root ?? '') as string, - destinationRoot: (value.destination_root ?? '') as string, + templateRoot: value.template_root as string | undefined, + destinationRoot: value.destination_root as string | undefined, context: context ? { projectName: typeof context.project_name === 'string' ? context.project_name : undefined diff --git a/packages/ast-template/test/session.integration.test.ts b/packages/ast-template/test/session.integration.test.ts index 7a0f542..a6ac630 100644 --- a/packages/ast-template/test/session.integration.test.ts +++ b/packages/ast-template/test/session.integration.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import { mkdirSync, readFileSync, rmSync } from 'node:fs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; @@ -10,12 +12,14 @@ import type { } from '@structuredmerge/ast-merge'; import type { SessionCommand, + SessionCommandPayload, SessionEntrypoint, SessionInspectionReport, SessionInvocation, SessionDiagnosticsReport, SessionOutcomeReport, SessionRequestReport, + SessionRunnerRequest, SessionStatusReport, SessionRunnerPayload } from '../src/index'; @@ -588,11 +592,11 @@ describe('template directory session report fixture', () => { }; for (const testCase of fixture.cases) { - expect(sessionStatusEnvelope(testCase.input as SessionStatusReport)).toEqual( + expect(sessionStatusEnvelope(testCase.input as unknown as SessionStatusReport)).toEqual( testCase.expected_envelope ); expect(importSessionStatusEnvelope(testCase.expected_envelope)).toEqual({ - status: testCase.input as SessionStatusReport + status: testCase.input as unknown as SessionStatusReport }); } }); @@ -645,7 +649,7 @@ describe('template directory session report fixture', () => { for (const testCase of fixture.cases) { expect(importSessionStatusEnvelope(testCase.envelope)).toEqual({ - status: testCase.expected as SessionStatusReport + status: testCase.expected as unknown as SessionStatusReport }); } @@ -674,11 +678,11 @@ describe('template directory session report fixture', () => { }; for (const testCase of fixture.cases) { - expect(sessionDiagnosticsEnvelope(testCase.input as SessionDiagnosticsReport)).toEqual( - testCase.expected_envelope - ); + expect( + sessionDiagnosticsEnvelope(testCase.input as unknown as SessionDiagnosticsReport) + ).toEqual(testCase.expected_envelope); expect(importSessionDiagnosticsEnvelope(testCase.expected_envelope)).toEqual({ - diagnostics: testCase.input as SessionDiagnosticsReport + diagnostics: testCase.input as unknown as SessionDiagnosticsReport }); } }); @@ -701,7 +705,7 @@ describe('template directory session report fixture', () => { }; for (const testCase of fixture.cases) { - expect(sessionOutcomeEnvelope(testCase.input as SessionOutcomeReport)).toEqual( + expect(sessionOutcomeEnvelope(testCase.input as unknown as SessionOutcomeReport)).toEqual( testCase.expected_envelope ); expect(importSessionOutcomeEnvelope(testCase.expected_envelope)).toEqual({ @@ -735,9 +739,11 @@ describe('template directory session report fixture', () => { fixtureRoot ); - expect(sessionInspectionEnvelope(input as SessionInspectionReport)).toEqual(expected); + expect(sessionInspectionEnvelope(input as unknown as SessionInspectionReport)).toEqual( + expected + ); expect(importSessionInspectionEnvelope(expected)).toEqual({ - inspection: input as SessionInspectionReport + inspection: input as unknown as SessionInspectionReport }); } }); @@ -797,7 +803,7 @@ describe('template directory session report fixture', () => { inspection: resolveSessionInspectionExpectedPaths( testCase.expected, fixtureRoot - ) as SessionInspectionReport + ) as unknown as SessionInspectionReport }); } @@ -857,7 +863,7 @@ describe('template directory session report fixture', () => { for (const testCase of fixture.cases) { expect(importSessionOutcomeEnvelope(testCase.envelope)).toEqual({ - outcome: testCase.expected as SessionOutcomeReport + outcome: testCase.expected as unknown as SessionOutcomeReport }); } @@ -1346,7 +1352,7 @@ describe('template directory session report fixture', () => { fixtureRoot ); - expect(sessionRequestEnvelope(input as SessionRequestReport)).toEqual(expected); + expect(sessionRequestEnvelope(input as unknown as SessionRequestReport)).toEqual(expected); expect(importSessionRequestEnvelope(expected)).toEqual({ request: input }); } }); @@ -1404,9 +1410,9 @@ describe('template directory session report fixture', () => { const envelope = resolveSessionRequestEnvelopeFixturePaths(testCase.envelope, fixtureRoot); const imported = importSessionRequestEnvelope(envelope); expect(imported.error).toBeUndefined(); - expect(runTemplateDirectorySessionRequest(imported.request as SessionRequestReport)).toEqual( - resolveSessionOutcomeExpectedPaths(testCase.expected, fixtureRoot) - ); + expect( + runTemplateDirectorySessionRequest(imported.request as unknown as SessionRequestReport) + ).toEqual(resolveSessionOutcomeExpectedPaths(testCase.expected, fixtureRoot)); } for (const testCase of fixture.rejections) { @@ -1553,7 +1559,9 @@ describe('template directory session report fixture', () => { fixtureRoot ); - expect(sessionRunnerPayloadEnvelope(input as SessionRunnerPayload)).toEqual(expected); + expect(sessionRunnerPayloadEnvelope(input as unknown as SessionRunnerPayload)).toEqual( + expected + ); expect(importSessionRunnerPayloadEnvelope(expected)).toEqual({ payload: input }); } }); @@ -1620,7 +1628,10 @@ describe('template directory session report fixture', () => { const imported = importSessionRunnerPayloadEnvelope(envelope); expect(imported.error).toBeUndefined(); expect( - runTemplateDirectorySessionRunnerPayload(imported.payload as SessionRunnerPayload, profiles) + runTemplateDirectorySessionRunnerPayload( + imported.payload as unknown as SessionRunnerPayload, + profiles + ) ).toEqual(resolveSessionOutcomeExpectedPaths(testCase.expected, fixtureRoot)); } @@ -1914,7 +1925,7 @@ describe('template directory session report fixture', () => { resolveSessionEntrypointFixturePaths( input.entrypoint as Record, fixtureRoot - ) as SessionEntrypoint, + ) as unknown as SessionEntrypoint, {} ) ).toThrow(testCase.expected_error); @@ -1941,9 +1952,9 @@ describe('template directory session report fixture', () => { for (const testCase of fixture.cases) { const input = resolveSessionCommandFixturePaths(testCase.input, fixtureRoot); - expect(() => runTemplateDirectorySessionCommand(input as SessionCommand, {})).toThrow( - testCase.expected_error - ); + expect(() => + runTemplateDirectorySessionCommand(input as unknown as SessionCommand, {}) + ).toThrow(testCase.expected_error); } }); @@ -1967,9 +1978,9 @@ describe('template directory session report fixture', () => { for (const testCase of fixture.cases) { const input = resolveSessionCommandPayloadFixturePaths(testCase.input, fixtureRoot); - expect(() => runTemplateDirectorySessionCommandPayload(input, {})).toThrow( - testCase.expected_error - ); + expect(() => + runTemplateDirectorySessionCommandPayload(input as unknown as SessionCommandPayload, {}) + ).toThrow(testCase.expected_error); } }); @@ -1998,7 +2009,7 @@ describe('template directory session report fixture', () => { fixtureRoot ); - expect(sessionCommandEnvelope(input as SessionCommand)).toEqual(expected); + expect(sessionCommandEnvelope(input as unknown as SessionCommand)).toEqual(expected); expect(importSessionCommandEnvelope(expected)).toEqual({ command: input }); } }); @@ -2113,7 +2124,7 @@ describe('template directory session report fixture', () => { fixtureRoot ); - expect(sessionEntrypointEnvelope(input as SessionEntrypoint)).toEqual(expected); + expect(sessionEntrypointEnvelope(input as unknown as SessionEntrypoint)).toEqual(expected); expect(importSessionEntrypointEnvelope(expected)).toEqual({ entrypoint: input }); } }); @@ -2210,7 +2221,10 @@ describe('template directory session report fixture', () => { const imported = importSessionRunnerRequestEnvelope(envelope); expect(imported.error).toBeUndefined(); expect( - runTemplateDirectorySessionRunnerRequest(imported.request as SessionRunnerRequest, profiles) + runTemplateDirectorySessionRunnerRequest( + imported.request as unknown as SessionRunnerRequest, + profiles + ) ).toEqual(resolveSessionOutcomeExpectedPaths(testCase.expected, fixtureRoot)); } @@ -2281,7 +2295,7 @@ describe('template directory session report fixture', () => { const imported = importSessionCommandEnvelope(envelope); expect(imported.error).toBeUndefined(); expect( - runTemplateDirectorySessionCommand(imported.command as SessionCommand, profiles) + runTemplateDirectorySessionCommand(imported.command as unknown as SessionCommand, profiles) ).toEqual(resolveSessionDispatchExpectedPaths(testCase.expected, fixtureRoot)); } @@ -2323,7 +2337,10 @@ describe('template directory session report fixture', () => { const imported = importSessionEntrypointEnvelope(envelope); expect(imported.error).toBeUndefined(); expect( - runTemplateDirectorySessionEntrypoint(imported.entrypoint as SessionEntrypoint, profiles) + runTemplateDirectorySessionEntrypoint( + imported.entrypoint as unknown as SessionEntrypoint, + profiles + ) ).toEqual(resolveSessionOutcomeExpectedPaths(testCase.expected, fixtureRoot)); } @@ -2369,7 +2386,7 @@ describe('template directory session report fixture', () => { expect(imported.error).toBeUndefined(); expect( runTemplateDirectorySessionCommandPayload( - imported.payload as SessionCommandPayload, + imported.payload as unknown as SessionCommandPayload, profiles ) ).toEqual(resolveSessionDispatchExpectedPaths(testCase.expected, fixtureRoot)); @@ -2439,7 +2456,7 @@ describe('template directory session report fixture', () => { for (const testCase of fixture.cases) { const input = resolveSessionInvocationFixturePaths(testCase.input, fixtureRoot); - expect(() => runTemplateDirectorySession(input as SessionInvocation, {})).toThrow( + expect(() => runTemplateDirectorySession(input as unknown as SessionInvocation, {})).toThrow( testCase.expected_error ); } @@ -2464,7 +2481,7 @@ describe('template directory session report fixture', () => { for (const testCase of fixture.cases) { const input = resolveSessionInvocationFixturePaths(testCase.input, fixtureRoot); - const roundTripped = JSON.parse(JSON.stringify(input)) as SessionInvocation; + const roundTripped = JSON.parse(JSON.stringify(input)) as unknown as SessionInvocation; expect(roundTripped).toEqual(input); } }); @@ -2557,7 +2574,7 @@ describe('template directory session report fixture', () => { const imported = importSessionInvocationEnvelope(envelope); expect(imported.error).toBeUndefined(); expect( - runTemplateDirectorySession(imported.invocation as SessionInvocation, profiles) + runTemplateDirectorySession(imported.invocation as unknown as SessionInvocation, profiles) ).toEqual(resolveSessionDispatchExpectedPaths(testCase.expected, fixtureRoot)); } @@ -2910,7 +2927,7 @@ function resolveSessionInvocationFixturePaths( if (typeof cloned.destination_root === 'string' && cloned.destination_root.length > 0) { cloned.destination_root = path.join(fixtureRoot, cloned.destination_root); } - return cloned as SessionInvocation; + return cloned as unknown as SessionInvocation; } function resolveSessionInvocationEnvelopeFixturePaths( diff --git a/packages/js-yaml-merge/test/fixtures.integration.test.ts b/packages/js-yaml-merge/test/fixtures.integration.test.ts index 17aced7..26cba54 100644 --- a/packages/js-yaml-merge/test/fixtures.integration.test.ts +++ b/packages/js-yaml-merge/test/fixtures.integration.test.ts @@ -36,7 +36,10 @@ function normalizeFixtureValue(value: T): T { } if (value && typeof value === 'object') { return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [toCamelCaseKey(key), normalizeFixtureValue(entry)]) + Object.entries(value).map(([key, entry]) => [ + toCamelCaseKey(key), + normalizeFixtureValue(entry) + ]) ) as T; } return value; @@ -46,11 +49,7 @@ describe('js-yaml-merge shared fixtures', () => { it('conforms to the provider feature-profile and plan-context fixtures', () => { const familyFixture = readFixture<{ feature_profile: Record; - }>( - 'diagnostics', - 'slice-95-yaml-family-feature-profile', - 'yaml-feature-profile.json' - ); + }>('diagnostics', 'slice-95-yaml-family-feature-profile', 'yaml-feature-profile.json'); const featureFixture = readFixture<{ providers: { js_yaml: { @@ -177,10 +176,12 @@ describe('js-yaml-merge shared fixtures', () => { ), (run) => { const key = `${run.ref.family}:${run.ref.role}:${run.ref.case}`; - return reportFixture.executions.js_yaml[key] ?? { - outcome: 'failed', - messages: ['missing execution'] - }; + return ( + reportFixture.executions.js_yaml[key] ?? { + outcome: 'failed', + messages: ['missing execution'] + } + ); } ); diff --git a/packages/json-merge/src/contracts.ts b/packages/json-merge/src/contracts.ts index 190fd61..eedc951 100644 --- a/packages/json-merge/src/contracts.ts +++ b/packages/json-merge/src/contracts.ts @@ -476,13 +476,13 @@ function mergeValues(template: unknown, destination: unknown): unknown { !Array.isArray(destination) ) { const merged: Record = {}; - const keys = new Set([ - ...Object.keys(template as Record), - ...Object.keys(destination as Record) - ]); - for (const key of [...keys].sort()) { - const templateRecord = template as Record; - const destinationRecord = destination as Record; + const templateRecord = template as Record; + const destinationRecord = destination as Record; + const keys = [ + ...Object.keys(templateRecord), + ...Object.keys(destinationRecord).filter((key) => !(key in templateRecord)) + ]; + for (const key of keys) { const hasTemplate = Object.prototype.hasOwnProperty.call(templateRecord, key); const hasDestination = Object.prototype.hasOwnProperty.call(destinationRecord, key); @@ -507,9 +507,9 @@ function canonicalJson(value: unknown): string { if (value && typeof value === 'object') { const record = value as Record; - const entries = Object.keys(record) - .sort() - .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`); + const entries = Object.keys(record).map( + (key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}` + ); return `{${entries.join(',')}}`; } diff --git a/packages/json-merge/test/contracts.test.ts b/packages/json-merge/test/contracts.test.ts index 94fa479..4edad64 100644 --- a/packages/json-merge/test/contracts.test.ts +++ b/packages/json-merge/test/contracts.test.ts @@ -52,7 +52,7 @@ describe('json-merge', () => { ); expect(result.ok).toBe(true); - expect(result.output).toBe('{"items":[9],"meta":{"mode":"template","tags":["destination"]}}'); + expect(result.output).toBe('{"items":[9],"meta":{"tags":["destination"],"mode":"template"}}'); expect(result.policies).toEqual([ { surface: 'array', diff --git a/packages/kettle-nodule/package.json b/packages/kettle-nodule/package.json new file mode 100644 index 0000000..4f00577 --- /dev/null +++ b/packages/kettle-nodule/package.json @@ -0,0 +1,10 @@ +{ + "name": "@structuredmerge/kettle-nodule", + "version": "0.0.0", + "private": false, + "type": "module", + "main": "./src/index.ts", + "dependencies": { + "@structuredmerge/ast-merge": "workspace:*" + } +} diff --git a/packages/kettle-nodule/src/index.ts b/packages/kettle-nodule/src/index.ts new file mode 100644 index 0000000..c57e5c4 --- /dev/null +++ b/packages/kettle-nodule/src/index.ts @@ -0,0 +1,577 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { + ContentRecipeExecutionReport, + ContentRecipeExecutionReportEnvelope, + ContentRecipeExecutionRequest, + ContentRecipeExecutionRequestEnvelope, + ContentRecipeStep, + ContentRecipeStepReport, + Diagnostic, + StructuredEditApplication, + StructuredEditOperationProfile, + StructuredEditResult +} from '@structuredmerge/ast-merge'; +import { STRUCTURED_EDIT_TRANSPORT_VERSION } from '@structuredmerge/ast-merge'; + +export const packageName = '@structuredmerge/kettle-nodule'; + +const MANAGED_BLOCK_OPEN = '// <> do not edit below this line'; +const MANAGED_BLOCK_CLOSE = '// <>'; + +export interface PackageFacts { + readonly package: { + readonly ecosystem: 'npm'; + readonly name: string; + readonly slug: string; + readonly description?: string; + readonly homepageUrl?: string; + readonly sourceUrl?: string; + readonly licenseExpression?: string; + }; + readonly npm: { + readonly packageJsonPath: 'package.json'; + readonly packageManager?: string; + readonly moduleType?: string; + }; + readonly funding?: { + readonly urls: readonly string[]; + }; +} + +export interface PackagingRecipe { + readonly name: + | 'readme_metadata' + | 'changelog_unreleased' + | 'generated_block_sync' + | 'template_source_application'; + readonly targetPath: string; + readonly providerFamily: 'markdown' | 'text'; + readonly primitive: + | 'supplied_readme_metadata_synchronization' + | 'changelog_unreleased_normalization' + | 'supplied_managed_text_block_replacement' + | 'supplied_template_source_application'; + readonly facts: readonly string[]; + readonly selectors: readonly string[]; +} + +export interface RecipePack { + readonly name: 'kettle-nodule-core' | 'kettle-nodule-packaged-template-inventory'; + readonly version: 1; + readonly ecosystem: 'npm'; + readonly recipes: readonly PackagingRecipe[]; +} + +export interface RecipeRunReport { + readonly recipeName: PackagingRecipe['name']; + readonly relativePath: string; + readonly changed: boolean; + readonly requestEnvelope: ContentRecipeExecutionRequestEnvelope; + readonly reportEnvelope: ContentRecipeExecutionReportEnvelope; + readonly finalContent: string; + readonly diagnostics: readonly Diagnostic[]; +} + +export interface ProjectReport { + readonly mode: 'plan' | 'apply'; + readonly ready: boolean; + readonly facts: PackageFacts; + readonly recipePack: RecipePack; + readonly recipeReports: readonly RecipeRunReport[]; + readonly changedFiles: readonly string[]; + readonly diagnostics: readonly Diagnostic[]; +} + +interface PackageJson { + readonly name?: string; + readonly description?: string; + readonly homepage?: string; + readonly repository?: string | { readonly url?: string }; + readonly license?: string; + readonly packageManager?: string; + readonly type?: string; + readonly funding?: string | { readonly url?: string } | Array; +} + +export function discoverFacts(projectRoot: string): PackageFacts { + const packageJsonPath = path.join(projectRoot, 'package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as PackageJson; + const name = packageJson.name ?? path.basename(projectRoot); + const fundingUrls = normalizeFundingUrls(packageJson.funding); + const facts: PackageFacts = { + package: compact({ + ecosystem: 'npm', + name, + slug: name, + description: packageJson.description, + homepageUrl: packageJson.homepage, + sourceUrl: repositoryUrl(packageJson.repository), + licenseExpression: packageJson.license + }), + npm: compact({ + packageJsonPath: 'package.json', + packageManager: packageJson.packageManager, + moduleType: packageJson.type + }) + }; + + return fundingUrls.length > 0 ? { ...facts, funding: { urls: fundingUrls } } : facts; +} + +export function recipePack(): RecipePack { + return { + name: 'kettle-nodule-core', + version: 1, + ecosystem: 'npm', + recipes: [ + recipeEntry( + 'readme_metadata', + 'README.md', + 'markdown', + 'supplied_readme_metadata_synchronization', + ['package', 'funding', 'readme'] + ), + recipeEntry( + 'changelog_unreleased', + 'CHANGELOG.md', + 'markdown', + 'changelog_unreleased_normalization', + ['package', 'changelog'] + ), + recipeEntry( + 'generated_block_sync', + 'src/generated-package-info.ts', + 'text', + 'supplied_managed_text_block_replacement', + ['package', 'generated_blocks'] + ) + ] + }; +} + +export function packagedTemplateInventoryPack(): RecipePack { + return { + name: 'kettle-nodule-packaged-template-inventory', + version: 1, + ecosystem: 'npm', + recipes: [ + templateRecipe('.editorconfig'), + templateRecipe('.github/workflows/ci.yml'), + templateRecipe('.gitignore'), + templateRecipe('.npmrc'), + templateRecipe('.prettierrc.json') + ] + }; +} + +export function planProject(projectRoot: string): ProjectReport { + const facts = discoverFacts(projectRoot); + const pack = recipePack(); + const files = readProjectFiles(projectRoot, pack); + const recipeReports = pack.recipes.map((recipe) => + executeRecipe({ projectRoot, recipe, facts, files }) + ); + const changedFiles = changedFilesForReports(recipeReports); + + return { + mode: 'plan', + ready: true, + facts, + recipePack: pack, + recipeReports, + changedFiles, + diagnostics: recipeReports.flatMap((report) => report.diagnostics) + }; +} + +export function planPackagedTemplateInventory(projectRoot: string): ProjectReport { + const facts = discoverFacts(projectRoot); + const pack = packagedTemplateInventoryPack(); + const files = readProjectFiles(projectRoot, pack); + const recipeReports = pack.recipes.map((recipe) => + executeRecipe({ projectRoot, recipe, facts, files }) + ); + + return { + mode: 'plan', + ready: true, + facts, + recipePack: pack, + recipeReports, + changedFiles: changedFilesForReports(recipeReports), + diagnostics: recipeReports.flatMap((report) => report.diagnostics) + }; +} + +export function applyPackagedTemplateInventory(projectRoot: string): ProjectReport { + const report = { ...planPackagedTemplateInventory(projectRoot), mode: 'apply' as const }; + for (const recipeReport of report.recipeReports) { + if (!recipeReport.changed) continue; + + const targetPath = path.join(projectRoot, recipeReport.relativePath); + mkdirSync(path.dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, recipeReport.finalContent); + } + return report; +} + +export function applyProject(projectRoot: string): ProjectReport { + const report = { ...planProject(projectRoot), mode: 'apply' as const }; + for (const recipeReport of report.recipeReports) { + if (!recipeReport.changed) continue; + + const targetPath = path.join(projectRoot, recipeReport.relativePath); + mkdirSync(path.dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, recipeReport.finalContent); + } + return report; +} + +function executeRecipe(input: { + readonly projectRoot: string; + readonly recipe: PackagingRecipe; + readonly facts: PackageFacts; + readonly files: Readonly>; +}): RecipeRunReport { + const original = input.files[input.recipe.targetPath] ?? ''; + const finalContent = + input.recipe.name === 'readme_metadata' + ? synchronizeReadme(original, input.facts) + : input.recipe.name === 'changelog_unreleased' + ? normalizeChangelog(original) + : input.recipe.name === 'generated_block_sync' + ? synchronizeManagedBlock(original, input.facts) + : renderPackagedTemplate(input.recipe.targetPath, input.facts); + const request = contentRecipeExecutionRequest({ + recipeName: input.recipe.primitive, + recipeVersion: '1', + relativePath: input.recipe.targetPath, + providerFamily: input.recipe.providerFamily, + templateContent: '', + destinationContent: original, + steps: [contentRecipeStep(input.recipe)], + runtimeContext: runtimeContext(input.facts), + metadata: { + packagingRecipe: input.recipe.name, + projectRoot: input.projectRoot + } + }); + const changed = finalContent !== original; + const stepReport = contentRecipeStepReport({ + recipe: input.recipe, + request, + original, + finalContent, + changed + }); + const report = contentRecipeExecutionReport({ + request, + finalContent, + changed, + stepReports: [stepReport], + diagnostics: [], + metadata: { packagingRecipe: input.recipe.name } + }); + + return { + recipeName: input.recipe.name, + relativePath: input.recipe.targetPath, + changed, + requestEnvelope: contentRecipeExecutionRequestEnvelope(request), + reportEnvelope: contentRecipeExecutionReportEnvelope(report), + finalContent, + diagnostics: [] + }; +} + +export function contentRecipeExecutionRequest( + request: ContentRecipeExecutionRequest +): ContentRecipeExecutionRequest { + return { ...request }; +} + +export function contentRecipeExecutionRequestEnvelope( + request: ContentRecipeExecutionRequest +): ContentRecipeExecutionRequestEnvelope { + return { + kind: 'content_recipe_execution_request', + version: STRUCTURED_EDIT_TRANSPORT_VERSION, + request + }; +} + +export function contentRecipeExecutionReport( + report: ContentRecipeExecutionReport +): ContentRecipeExecutionReport { + return { ...report }; +} + +export function contentRecipeExecutionReportEnvelope( + report: ContentRecipeExecutionReport +): ContentRecipeExecutionReportEnvelope { + return { + kind: 'content_recipe_execution_report', + version: STRUCTURED_EDIT_TRANSPORT_VERSION, + report + }; +} + +function contentRecipeStep(recipe: PackagingRecipe): ContentRecipeStep { + return { + stepId: recipe.name, + stepKind: 'structured_edit', + name: recipe.name, + providerFamily: recipe.providerFamily, + metadata: { primitive: recipe.primitive, targetPath: recipe.targetPath } + }; +} + +function contentRecipeStepReport(input: { + readonly recipe: PackagingRecipe; + readonly request: ContentRecipeExecutionRequest; + readonly original: string; + readonly finalContent: string; + readonly changed: boolean; +}): ContentRecipeStepReport { + const operationProfile: StructuredEditOperationProfile = { + operationKind: input.recipe.primitive, + operationFamily: 'kettle-nodule', + knownOperationKind: true, + sourceRequirement: 'destination_content', + destinationRequirement: 'relative_path', + replacementSource: 'runtime_context', + capturesSourceText: false, + supportsIfMissing: true + }; + const result: StructuredEditResult = { + operationKind: input.recipe.primitive, + updatedContent: input.finalContent, + changed: input.changed, + operationProfile + }; + const application: StructuredEditApplication = { + request: { + operationKind: input.recipe.primitive, + content: input.original, + sourceLabel: input.recipe.targetPath, + metadata: { packagingRecipe: input.recipe.name } + }, + result + }; + + return { + stepId: input.recipe.name, + stepKind: input.recipe.primitive, + status: input.changed ? 'applied' : 'unchanged', + changed: input.changed, + inputContent: input.original, + outputContent: input.finalContent, + application, + diagnostics: [], + metadata: { targetPath: input.recipe.targetPath } + }; +} + +function synchronizeReadme(content: string, facts: PackageFacts): string { + const lines = content.split('\n'); + const heading = `# ${facts.package.name}`; + const h1Index = lines.findIndex((line) => line.startsWith('# ')); + if (h1Index >= 0) { + lines[h1Index] = heading; + } else { + lines.unshift(heading, ''); + } + return replaceMarkdownManagedBlock( + lines.join('\n'), + 'kettle-nodule:metadata', + readmeMetadataBlock(facts) + ); +} + +function normalizeChangelog(content: string): string { + let text = content; + if (!text.split('\n')[0]?.startsWith('# ')) { + text = `# Changelog\n\n${text}`; + } + if (/^##\s+\[?Unreleased\]?/im.test(text)) { + return ensureTrailingNewline(text); + } + + const lines = text.split('\n'); + const insertAt = lines.findIndex((line) => line.startsWith('## ')); + const section = ['', '## [Unreleased]', '', '### Added', '', '### Changed', '', '### Fixed', '']; + lines.splice(insertAt >= 0 ? insertAt : lines.length, 0, ...section); + return ensureTrailingNewline(lines.join('\n').replace(/\n{3,}/g, '\n\n')); +} + +function synchronizeManagedBlock(content: string, facts: PackageFacts): string { + const replacement = [ + MANAGED_BLOCK_OPEN, + `export const packageName = ${JSON.stringify(facts.package.name)};`, + `export const packageEcosystem = ${JSON.stringify(facts.package.ecosystem)};`, + MANAGED_BLOCK_CLOSE, + '' + ].join('\n'); + return replaceTextManagedBlock(content, replacement); +} + +function readProjectFiles(projectRoot: string, pack: RecipePack): Record { + return Object.fromEntries( + pack.recipes.map((recipe) => { + const targetPath = path.join(projectRoot, recipe.targetPath); + return [recipe.targetPath, existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : '']; + }) + ); +} + +function runtimeContext(facts: PackageFacts): Readonly> { + return facts.funding + ? { package: facts.package, npm: facts.npm, funding: facts.funding } + : { package: facts.package, npm: facts.npm }; +} + +function recipeEntry( + name: PackagingRecipe['name'], + targetPath: string, + providerFamily: PackagingRecipe['providerFamily'], + primitive: PackagingRecipe['primitive'], + facts: readonly string[] +): PackagingRecipe { + return { + name, + targetPath, + providerFamily, + primitive, + facts, + selectors: [] + }; +} + +function templateRecipe(targetPath: string): PackagingRecipe { + return recipeEntry( + 'template_source_application', + targetPath, + 'text', + 'supplied_template_source_application', + ['package', 'templates'] + ); +} + +function changedFilesForReports(reports: readonly RecipeRunReport[]): readonly string[] { + return reports + .filter((report) => report.changed) + .map((report) => report.relativePath) + .sort(); +} + +function renderPackagedTemplate(targetPath: string, facts: PackageFacts): string { + return packagedTemplateContent(targetPath) + .replaceAll('{{PACKAGE_NAME}}', facts.package.name) + .replaceAll('{{PACKAGE_MANAGER_COMMAND}}', packageManagerCommand(facts.npm.packageManager)) + .replaceAll('{{NODE_VERSION}}', nodeVersionFromPackageManager(facts.npm.packageManager)); +} + +function packagedTemplateContent(targetPath: string): string { + switch (targetPath) { + case '.editorconfig': + return 'root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n'; + case '.github/workflows/ci.yml': + return 'name: CI\n\non:\n push:\n pull_request:\n\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - uses: actions/setup-node@v4\n with:\n node-version: "{{NODE_VERSION}}"\n - run: corepack enable\n - run: {{PACKAGE_MANAGER_COMMAND}} install\n - run: {{PACKAGE_MANAGER_COMMAND}} test\n'; + case '.gitignore': + return 'node_modules/\ncoverage/\ndist/\n'; + case '.npmrc': + return 'engine-strict=true\nfund=true\n'; + case '.prettierrc.json': + return '{\n "singleQuote": true,\n "trailingComma": "none"\n}\n'; + default: + return ''; + } +} + +function nodeVersionFromPackageManager(packageManager: string | undefined): string { + const match = packageManager?.match(/node@(\d+(?:\.\d+){0,2})/); + return match?.[1] ?? '20'; +} + +function packageManagerCommand(packageManager: string | undefined): string { + return packageManager?.split('@')[0] || 'npm'; +} + +function readmeMetadataBlock(facts: PackageFacts): string { + const rows = [ + ['Package', facts.package.name], + ['Description', facts.package.description], + ['Homepage', facts.package.homepageUrl], + ['Source', facts.package.sourceUrl], + ['License', facts.package.licenseExpression] + ].filter((row): row is [string, string] => typeof row[1] === 'string' && row[1].length > 0); + + return [ + '', + '| Field | Value |', + '|---|---|', + ...rows.map(([field, value]) => `| ${field} | ${value} |`), + '' + ].join('\n'); +} + +function replaceMarkdownManagedBlock(content: string, marker: string, replacement: string): string { + return replaceBetweenMarkers( + content, + ``, + ``, + replacement, + () => `${content.trimEnd()}\n\n${replacement}\n` + ); +} + +function replaceTextManagedBlock(content: string, replacement: string): string { + return replaceBetweenMarkers(content, MANAGED_BLOCK_OPEN, MANAGED_BLOCK_CLOSE, replacement, () => + [content.trimEnd(), replacement].filter((part) => part.length > 0).join('\n') + ); +} + +function replaceBetweenMarkers( + content: string, + openMarker: string, + closeMarker: string, + replacement: string, + fallback: () => string +): string { + const openIndex = content.indexOf(openMarker); + const closeIndex = content.indexOf(closeMarker); + if (openIndex < 0 || closeIndex < openIndex) return fallback(); + + let closeEnd = closeIndex + closeMarker.length; + if (content[closeEnd] === '\n') closeEnd += 1; + return `${content.slice(0, openIndex)}${replacement}\n${content.slice(closeEnd)}`; +} + +function repositoryUrl(repository: PackageJson['repository']): string | undefined { + if (typeof repository === 'string') return repository; + return repository?.url; +} + +function normalizeFundingUrls(funding: PackageJson['funding']): readonly string[] { + if (!funding) return []; + const entries = Array.isArray(funding) ? funding : [funding]; + return entries + .map((entry) => (typeof entry === 'string' ? entry : entry.url)) + .filter((entry): entry is string => typeof entry === 'string' && entry.length > 0) + .sort(); +} + +function ensureTrailingNewline(text: string): string { + return text.endsWith('\n') ? text : `${text}\n`; +} + +function compact>(record: T): T { + return Object.fromEntries( + Object.entries(record).filter(([, value]) => { + if (value === undefined || value === null) return false; + if (typeof value === 'string' || Array.isArray(value)) return value.length > 0; + return true; + }) + ) as T; +} diff --git a/packages/kettle-nodule/test/fixtures/thin-slice.json b/packages/kettle-nodule/test/fixtures/thin-slice.json new file mode 100644 index 0000000..72414c8 --- /dev/null +++ b/packages/kettle-nodule/test/fixtures/thin-slice.json @@ -0,0 +1,36 @@ +{ + "case_id": "kettle-nodule-vnext-readme-changelog-managed-block", + "ecosystem": "npm", + "inputs": { + "files": { + "package.json": "{\n \"name\": \"@example/nodule\",\n \"description\": \"Example npm package\",\n \"homepage\": \"https://example.test/nodule\",\n \"repository\": {\n \"url\": \"https://github.com/example/nodule\"\n },\n \"license\": \"MIT\",\n \"type\": \"module\",\n \"packageManager\": \"pnpm@10.19.0\"\n}\n", + "README.md": "# Old Package\n\nExisting npm intro.\n", + "CHANGELOG.md": "# Changelog\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", + "src/generated-package-info.ts": "export const localValue = true;\n" + } + }, + "expected": { + "facts": { + "package": { + "ecosystem": "npm", + "name": "@example/nodule", + "slug": "@example/nodule", + "description": "Example npm package", + "homepageUrl": "https://example.test/nodule", + "sourceUrl": "https://github.com/example/nodule", + "licenseExpression": "MIT" + }, + "npm": { + "packageJsonPath": "package.json", + "packageManager": "pnpm@10.19.0", + "moduleType": "module" + } + }, + "changedFiles": ["CHANGELOG.md", "README.md", "src/generated-package-info.ts"], + "files": { + "README.md": "# @example/nodule\n\nExisting npm intro.\n\n\n| Field | Value |\n|---|---|\n| Package | @example/nodule |\n| Description | Example npm package |\n| Homepage | https://example.test/nodule |\n| Source | https://github.com/example/nodule |\n| License | MIT |\n\n", + "CHANGELOG.md": "# Changelog\n\n## [Unreleased]\n\n### Added\n\n### Changed\n\n### Fixed\n\n## [0.1.0] - 2026-01-01\n\n- Initial release.\n", + "src/generated-package-info.ts": "export const localValue = true;\n// <> do not edit below this line\nexport const packageName = \"@example/nodule\";\nexport const packageEcosystem = \"npm\";\n// <>\n" + } + } +} diff --git a/packages/kettle-nodule/test/thin-slice.test.ts b/packages/kettle-nodule/test/thin-slice.test.ts new file mode 100644 index 0000000..2e73bad --- /dev/null +++ b/packages/kettle-nodule/test/thin-slice.test.ts @@ -0,0 +1,139 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + applyPackagedTemplateInventory, + applyProject, + planPackagedTemplateInventory, + planProject +} from '../src/index'; + +interface ThinSliceFixture { + readonly case_id: string; + readonly ecosystem: string; + readonly inputs: { + readonly files: Record; + }; + readonly expected: { + readonly facts: unknown; + readonly changedFiles: readonly string[]; + readonly files: Record; + }; +} + +interface ThinSliceContract { + readonly canonical_recipes: readonly { readonly name: string }[]; + readonly required_fact_groups: readonly string[]; + readonly ecosystem_fact_groups: Record; + readonly report_contract: { + readonly request_envelope_kind: string; + readonly report_envelope_kind: string; + }; + readonly validated_ecosystems: readonly string[]; +} + +function writeTree(root: string, files: Record): void { + for (const [relativePath, content] of Object.entries(files)) { + const targetPath = path.join(root, relativePath); + mkdirSync(path.dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, content); + } +} + +function readProjectFiles(root: string, paths: readonly string[]): Record { + return Object.fromEntries( + paths.map((relativePath) => [relativePath, readFileSync(path.join(root, relativePath), 'utf8')]) + ); +} + +describe('kettle-nodule thin vertical slice', () => { + it('plans and applies npm package templating requests', () => { + const fixturePath = path.resolve(import.meta.dirname, 'fixtures', 'thin-slice.json'); + const contractPath = path.resolve( + import.meta.dirname, + '..', + '..', + '..', + '..', + 'fixtures', + 'packaging', + 'thin-slice-contract.json' + ); + const fixture = JSON.parse(readFileSync(fixturePath, 'utf8')) as ThinSliceFixture; + const contract = JSON.parse(readFileSync(contractPath, 'utf8')) as ThinSliceContract; + const projectRoot = path.resolve(import.meta.dirname, '..', 'tmp', 'thin-slice'); + rmSync(projectRoot, { force: true, recursive: true }); + const expectedRecipeNames = contract.canonical_recipes.map((recipe) => recipe.name); + expect(contract.validated_ecosystems).toContain(fixture.ecosystem); + expect(Object.keys(fixture.expected.facts as Record)).toEqual( + expect.arrayContaining([ + ...contract.required_fact_groups, + contract.ecosystem_fact_groups[fixture.ecosystem] + ]) + ); + + writeTree(projectRoot, fixture.inputs.files); + + const plan = planProject(projectRoot); + expect(plan.facts).toEqual(fixture.expected.facts); + expect(plan.recipePack.recipes.map((recipe) => recipe.name)).toEqual(expectedRecipeNames); + expect(plan.changedFiles).toEqual(fixture.expected.changedFiles); + expect([...new Set(plan.recipeReports.map((report) => report.requestEnvelope.kind))]).toEqual([ + contract.report_contract.request_envelope_kind + ]); + expect([...new Set(plan.recipeReports.map((report) => report.reportEnvelope.kind))]).toEqual([ + contract.report_contract.report_envelope_kind + ]); + + const apply = applyProject(projectRoot); + expect(apply.changedFiles).toEqual(fixture.expected.changedFiles); + expect(readProjectFiles(projectRoot, Object.keys(fixture.expected.files))).toEqual( + fixture.expected.files + ); + rmSync(projectRoot, { force: true, recursive: true }); + }); + + it('plans, applies, and reapplies packaged template inventory', () => { + const projectRoot = path.resolve( + import.meta.dirname, + '..', + 'tmp', + 'packaged-template-inventory' + ); + rmSync(projectRoot, { force: true, recursive: true }); + writeTree(projectRoot, { + 'package.json': JSON.stringify( + { + name: '@acme/widget', + version: '0.1.0', + packageManager: 'pnpm@9.1.0', + type: 'module' + }, + null, + 2 + ) + }); + + const expectedChanged = [ + '.editorconfig', + '.github/workflows/ci.yml', + '.gitignore', + '.npmrc', + '.prettierrc.json' + ]; + const plan = planPackagedTemplateInventory(projectRoot); + expect(plan.recipePack.name).toBe('kettle-nodule-packaged-template-inventory'); + expect(plan.changedFiles).toEqual(expectedChanged); + + const apply = applyPackagedTemplateInventory(projectRoot); + expect(apply.changedFiles).toEqual(expectedChanged); + const ci = readFileSync(path.join(projectRoot, '.github/workflows/ci.yml'), 'utf8'); + expect(ci).toContain('node-version: "20"'); + expect(ci).toContain('- run: pnpm test'); + + const second = applyPackagedTemplateInventory(projectRoot); + expect(second.changedFiles).toEqual([]); + expect(readFileSync(path.join(projectRoot, '.github/workflows/ci.yml'), 'utf8')).toBe(ci); + rmSync(projectRoot, { force: true, recursive: true }); + }); +}); diff --git a/packages/markdown-it-merge/test/contracts.test.ts b/packages/markdown-it-merge/test/contracts.test.ts index 38d9b77..c0ea385 100644 --- a/packages/markdown-it-merge/test/contracts.test.ts +++ b/packages/markdown-it-merge/test/contracts.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + import { describe, expect, it } from 'vitest'; import { registeredBackends } from '@structuredmerge/tree-haver'; import { diff --git a/packages/markdown-it-merge/test/fixtures.integration.test.ts b/packages/markdown-it-merge/test/fixtures.integration.test.ts index 9ec607d..4fc5511 100644 --- a/packages/markdown-it-merge/test/fixtures.integration.test.ts +++ b/packages/markdown-it-merge/test/fixtures.integration.test.ts @@ -59,8 +59,9 @@ describe('markdown-it-merge shared fixtures', () => { supportedDialects: providerProfile.supported_dialects, supportedPolicies: providerProfile.supported_policies, backend: providerProfile.backend, - backendRef: (providerProfile.backendRef ?? - providerProfile.backend_ref) as Record | undefined + backendRef: (providerProfile.backendRef ?? providerProfile.backend_ref) as + | Record + | undefined }); }); @@ -157,49 +158,53 @@ describe('markdown-it-merge shared fixtures', () => { ); const reviewState = { - requests: ((fixture.review_state as Record).requests as Array>).map( - (request) => ({ - id: request.id as string, - kind: request.kind as 'delegated_child_group', - family: request.family as string, - message: request.message as string, - blocking: request.blocking as boolean, - delegatedGroup: { - delegatedApplyGroup: (request.delegated_group as Record) - .delegated_apply_group as string, - parentOperationId: (request.delegated_group as Record) - .parent_operation_id as string, - childOperationId: (request.delegated_group as Record) - .child_operation_id as string, - delegatedRuntimeSurfacePath: (request.delegated_group as Record) - .delegated_runtime_surface_path as string, - caseIds: (request.delegated_group as Record).case_ids as string[], - delegatedCaseIds: (request.delegated_group as Record) - .delegated_case_ids as string[] - }, - actionOffers: (request.action_offers as Array>).map((offer) => ({ - action: offer.action as 'apply_delegated_child_group', - requiresContext: offer.requires_context as boolean - })), - defaultAction: request.default_action as 'apply_delegated_child_group' - }) - ), - acceptedGroups: ((fixture.review_state as Record).accepted_groups as Array>).map( - (group) => ({ - delegatedApplyGroup: group.delegated_apply_group as string, - parentOperationId: group.parent_operation_id as string, - childOperationId: group.child_operation_id as string, - delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path as string, - caseIds: group.case_ids as string[], - delegatedCaseIds: group.delegated_case_ids as string[] - }) - ), - appliedDecisions: ((fixture.review_state as Record).applied_decisions as Array>).map( - (decision) => ({ - requestId: decision.request_id as string, - action: decision.action as 'apply_delegated_child_group' - }) - ), + requests: ( + (fixture.review_state as Record).requests as Array> + ).map((request) => ({ + id: request.id as string, + kind: request.kind as 'delegated_child_group', + family: request.family as string, + message: request.message as string, + blocking: request.blocking as boolean, + delegatedGroup: { + delegatedApplyGroup: (request.delegated_group as Record) + .delegated_apply_group as string, + parentOperationId: (request.delegated_group as Record) + .parent_operation_id as string, + childOperationId: (request.delegated_group as Record) + .child_operation_id as string, + delegatedRuntimeSurfacePath: (request.delegated_group as Record) + .delegated_runtime_surface_path as string, + caseIds: (request.delegated_group as Record).case_ids as string[], + delegatedCaseIds: (request.delegated_group as Record) + .delegated_case_ids as string[] + }, + actionOffers: (request.action_offers as Array>).map((offer) => ({ + action: offer.action as 'apply_delegated_child_group', + requiresContext: offer.requires_context as boolean + })), + defaultAction: request.default_action as 'apply_delegated_child_group' + })), + acceptedGroups: ( + (fixture.review_state as Record).accepted_groups as Array< + Record + > + ).map((group) => ({ + delegatedApplyGroup: group.delegated_apply_group as string, + parentOperationId: group.parent_operation_id as string, + childOperationId: group.child_operation_id as string, + delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path as string, + caseIds: group.case_ids as string[], + delegatedCaseIds: group.delegated_case_ids as string[] + })), + appliedDecisions: ( + (fixture.review_state as Record).applied_decisions as Array< + Record + > + ).map((decision) => ({ + requestId: decision.request_id as string, + action: decision.action as 'apply_delegated_child_group' + })), diagnostics: ((fixture.review_state as Record).diagnostics ?? []) as [] }; @@ -227,92 +232,145 @@ describe('markdown-it-merge shared fixtures', () => { const sharedFixturePath = providerFixture.shared_fixture_path as string[]; const fixture = readFixture(...sharedFixturePath); const expected = ( - ((providerFixture.providers as Record>)['markdown-it'] as Record< - string, - unknown - >).expected as Record - ); + (providerFixture.providers as Record>)[ + 'markdown-it' + ] as Record + ).expected as Record; const replayBundle = { replayContext: { - surface: ((fixture.replay_bundle as Record).replay_context as Record) - .surface as 'conformance_manifest', - families: ((fixture.replay_bundle as Record).replay_context as Record) - .families as string[], - requireExplicitContexts: ((fixture.replay_bundle as Record).replay_context as Record) - .require_explicit_contexts as boolean + surface: ( + (fixture.replay_bundle as Record).replay_context as Record< + string, + unknown + > + ).surface as 'conformance_manifest', + families: ( + (fixture.replay_bundle as Record).replay_context as Record< + string, + unknown + > + ).families as string[], + requireExplicitContexts: ( + (fixture.replay_bundle as Record).replay_context as Record< + string, + unknown + > + ).require_explicit_contexts as boolean }, - decisions: ((fixture.replay_bundle as Record).decisions as Array>).map( - (decision) => ({ - requestId: decision.request_id as string, - action: decision.action as 'accept_default_context' | 'apply_delegated_child_group' - }) - ), - reviewedNestedExecutions: ((fixture.replay_bundle as Record).reviewed_nested_executions as Array>).map( - (execution) => ({ - family: execution.family as string, - reviewState: { - requests: ((execution.review_state as Record).requests as Array>).map( - (request) => ({ - id: request.id as string, - kind: request.kind as 'delegated_child_group', - family: request.family as string, - message: request.message as string, - blocking: request.blocking as boolean, - delegatedGroup: { - delegatedApplyGroup: (request.delegated_group as Record).delegated_apply_group as string, - parentOperationId: (request.delegated_group as Record).parent_operation_id as string, - childOperationId: (request.delegated_group as Record).child_operation_id as string, - delegatedRuntimeSurfacePath: (request.delegated_group as Record).delegated_runtime_surface_path as string, - caseIds: (request.delegated_group as Record).case_ids as string[], - delegatedCaseIds: (request.delegated_group as Record).delegated_case_ids as string[] - }, - actionOffers: (request.action_offers as Array>).map((offer) => ({ - action: offer.action as 'apply_delegated_child_group', - requiresContext: offer.requires_context as boolean - })), - defaultAction: request.default_action as 'apply_delegated_child_group' - }) - ), - acceptedGroups: ((execution.review_state as Record).accepted_groups as Array>).map( - (group) => ({ - delegatedApplyGroup: group.delegated_apply_group as string, - parentOperationId: group.parent_operation_id as string, - childOperationId: group.child_operation_id as string, - delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path as string, - caseIds: group.case_ids as string[], - delegatedCaseIds: group.delegated_case_ids as string[] - }) - ), - appliedDecisions: ((execution.review_state as Record).applied_decisions as Array>).map( - (decision) => ({ - requestId: decision.request_id as string, - action: decision.action as 'apply_delegated_child_group' + decisions: ( + (fixture.replay_bundle as Record).decisions as Array< + Record + > + ).map((decision) => ({ + requestId: decision.request_id as string, + action: decision.action as 'accept_default_context' | 'apply_delegated_child_group' + })), + reviewedNestedExecutions: ( + (fixture.replay_bundle as Record).reviewed_nested_executions as Array< + Record + > + ).map((execution) => ({ + family: execution.family as string, + reviewState: { + requests: ( + (execution.review_state as Record).requests as Array< + Record + > + ).map((request) => ({ + id: request.id as string, + kind: request.kind as 'delegated_child_group', + family: request.family as string, + message: request.message as string, + blocking: request.blocking as boolean, + delegatedGroup: { + delegatedApplyGroup: (request.delegated_group as Record) + .delegated_apply_group as string, + parentOperationId: (request.delegated_group as Record) + .parent_operation_id as string, + childOperationId: (request.delegated_group as Record) + .child_operation_id as string, + delegatedRuntimeSurfacePath: (request.delegated_group as Record) + .delegated_runtime_surface_path as string, + caseIds: (request.delegated_group as Record).case_ids as string[], + delegatedCaseIds: (request.delegated_group as Record) + .delegated_case_ids as string[] + }, + actionOffers: (request.action_offers as Array>).map( + (offer) => ({ + action: offer.action as 'apply_delegated_child_group', + requiresContext: offer.requires_context as boolean }) ), - diagnostics: ((execution.review_state as Record).diagnostics ?? []) as [] - }, - appliedChildren: (execution.applied_children as Array>).map((entry) => ({ + defaultAction: request.default_action as 'apply_delegated_child_group' + })), + acceptedGroups: ( + (execution.review_state as Record).accepted_groups as Array< + Record + > + ).map((group) => ({ + delegatedApplyGroup: group.delegated_apply_group as string, + parentOperationId: group.parent_operation_id as string, + childOperationId: group.child_operation_id as string, + delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path as string, + caseIds: group.case_ids as string[], + delegatedCaseIds: group.delegated_case_ids as string[] + })), + appliedDecisions: ( + (execution.review_state as Record).applied_decisions as Array< + Record + > + ).map((decision) => ({ + requestId: decision.request_id as string, + action: decision.action as 'apply_delegated_child_group' + })), + diagnostics: ((execution.review_state as Record).diagnostics ?? []) as [] + }, + appliedChildren: (execution.applied_children as Array>).map( + (entry) => ({ operationId: entry.operation_id as string, output: entry.output as string - })) - }) - ) + }) + ) + })) }; const reviewState = { - report: (fixture.review_state as Record).report as { entries: []; summary: { total: 0; passed: 0; failed: 0; skipped: 0 } }, + report: (fixture.review_state as Record).report as { + entries: []; + summary: { total: 0; passed: 0; failed: 0; skipped: 0 }; + }, diagnostics: ((fixture.review_state as Record).diagnostics ?? []) as [], requests: ((fixture.review_state as Record).requests ?? []) as [], - appliedDecisions: ((fixture.review_state as Record).applied_decisions ?? []) as [], + appliedDecisions: ((fixture.review_state as Record).applied_decisions ?? + []) as [], hostHints: { - interactive: ((fixture.review_state as Record).host_hints as Record).interactive as boolean, - requireExplicitContexts: ((fixture.review_state as Record).host_hints as Record).require_explicit_contexts as boolean + interactive: ( + (fixture.review_state as Record).host_hints as Record + ).interactive as boolean, + requireExplicitContexts: ( + (fixture.review_state as Record).host_hints as Record + ).require_explicit_contexts as boolean }, replayContext: { - surface: ((fixture.review_state as Record).replay_context as Record).surface as 'conformance_manifest', - families: ((fixture.review_state as Record).replay_context as Record).families as string[], - requireExplicitContexts: ((fixture.review_state as Record).replay_context as Record).require_explicit_contexts as boolean + surface: ( + (fixture.review_state as Record).replay_context as Record< + string, + unknown + > + ).surface as 'conformance_manifest', + families: ( + (fixture.review_state as Record).replay_context as Record< + string, + unknown + > + ).families as string[], + requireExplicitContexts: ( + (fixture.review_state as Record).replay_context as Record< + string, + unknown + > + ).require_explicit_contexts as boolean }, reviewedNestedExecutions: replayBundle.reviewedNestedExecutions }; @@ -384,11 +442,10 @@ describe('markdown-it-merge shared fixtures', () => { const sharedFixturePath = providerFixture.shared_fixture_path as string[]; const fixture = readFixture(...sharedFixturePath); const expected = ( - ((providerFixture.providers as Record>)['markdown-it'] as Record< - string, - unknown - >).expected as Record - ); + (providerFixture.providers as Record>)[ + 'markdown-it' + ] as Record + ).expected as Record; const replayResult = mergeMarkdownWithReviewedNestedOutputsFromReplayBundleEnvelope( fixture.template as string, @@ -399,38 +456,88 @@ describe('markdown-it-merge shared fixtures', () => { version: 1, replayBundle: { replayContext: { - surface: (((fixture.replay_bundle_envelope as Record).replay_bundle as Record).replay_context as Record).surface as 'conformance_manifest', - families: (((fixture.replay_bundle_envelope as Record).replay_bundle as Record).replay_context as Record).families as string[], - requireExplicitContexts: ((((fixture.replay_bundle_envelope as Record).replay_bundle as Record).replay_context as Record).require_explicit_contexts as boolean) + surface: ( + ( + (fixture.replay_bundle_envelope as Record).replay_bundle as Record< + string, + unknown + > + ).replay_context as Record + ).surface as 'conformance_manifest', + families: ( + ( + (fixture.replay_bundle_envelope as Record).replay_bundle as Record< + string, + unknown + > + ).replay_context as Record + ).families as string[], + requireExplicitContexts: ( + ( + (fixture.replay_bundle_envelope as Record).replay_bundle as Record< + string, + unknown + > + ).replay_context as Record + ).require_explicit_contexts as boolean }, - decisions: ((((fixture.replay_bundle_envelope as Record).replay_bundle as Record).decisions ?? []) as Array>).map((decision) => ({ + decisions: ( + (( + (fixture.replay_bundle_envelope as Record).replay_bundle as Record< + string, + unknown + > + ).decisions ?? []) as Array> + ).map((decision) => ({ requestId: decision.request_id as string, action: decision.action as 'accept_default_context' | 'apply_delegated_child_group' })), - reviewedNestedExecutions: ((((fixture.replay_bundle_envelope as Record).replay_bundle as Record).reviewed_nested_executions ?? []) as Array>).map((execution) => ({ + reviewedNestedExecutions: ( + (( + (fixture.replay_bundle_envelope as Record).replay_bundle as Record< + string, + unknown + > + ).reviewed_nested_executions ?? []) as Array> + ).map((execution) => ({ family: execution.family as string, reviewState: { - requests: (((execution.review_state as Record).requests ?? []) as Array>).map((request) => ({ + requests: ( + ((execution.review_state as Record).requests ?? []) as Array< + Record + > + ).map((request) => ({ id: request.id as string, kind: request.kind as 'delegated_child_group', family: request.family as string, message: request.message as string, blocking: request.blocking as boolean, delegatedGroup: { - delegatedApplyGroup: (request.delegated_group as Record).delegated_apply_group as string, - parentOperationId: (request.delegated_group as Record).parent_operation_id as string, - childOperationId: (request.delegated_group as Record).child_operation_id as string, - delegatedRuntimeSurfacePath: (request.delegated_group as Record).delegated_runtime_surface_path as string, - caseIds: (request.delegated_group as Record).case_ids as string[], - delegatedCaseIds: (request.delegated_group as Record).delegated_case_ids as string[] + delegatedApplyGroup: (request.delegated_group as Record) + .delegated_apply_group as string, + parentOperationId: (request.delegated_group as Record) + .parent_operation_id as string, + childOperationId: (request.delegated_group as Record) + .child_operation_id as string, + delegatedRuntimeSurfacePath: (request.delegated_group as Record) + .delegated_runtime_surface_path as string, + caseIds: (request.delegated_group as Record) + .case_ids as string[], + delegatedCaseIds: (request.delegated_group as Record) + .delegated_case_ids as string[] }, - actionOffers: (request.action_offers as Array>).map((offer) => ({ - action: offer.action as 'apply_delegated_child_group', - requiresContext: offer.requires_context as boolean - })), + actionOffers: (request.action_offers as Array>).map( + (offer) => ({ + action: offer.action as 'apply_delegated_child_group', + requiresContext: offer.requires_context as boolean + }) + ), defaultAction: request.default_action as 'apply_delegated_child_group' })), - acceptedGroups: (((execution.review_state as Record).accepted_groups ?? []) as Array>).map((group) => ({ + acceptedGroups: ( + ((execution.review_state as Record).accepted_groups ?? + []) as Array> + ).map((group) => ({ delegatedApplyGroup: group.delegated_apply_group as string, parentOperationId: group.parent_operation_id as string, childOperationId: group.child_operation_id as string, @@ -438,16 +545,22 @@ describe('markdown-it-merge shared fixtures', () => { caseIds: group.case_ids as string[], delegatedCaseIds: group.delegated_case_ids as string[] })), - appliedDecisions: (((execution.review_state as Record).applied_decisions ?? []) as Array>).map((decision) => ({ + appliedDecisions: ( + ((execution.review_state as Record).applied_decisions ?? + []) as Array> + ).map((decision) => ({ requestId: decision.request_id as string, action: decision.action as 'apply_delegated_child_group' })), - diagnostics: ((execution.review_state as Record).diagnostics ?? []) as [] + diagnostics: ((execution.review_state as Record).diagnostics ?? + []) as [] }, - appliedChildren: (execution.applied_children as Array>).map((entry) => ({ - operationId: entry.operation_id as string, - output: entry.output as string - })) + appliedChildren: (execution.applied_children as Array>).map( + (entry) => ({ + operationId: entry.operation_id as string, + output: entry.output as string + }) + ) })) } } @@ -463,24 +576,89 @@ describe('markdown-it-merge shared fixtures', () => { kind: 'conformance_manifest_review_state', version: 1, state: { - report: ((fixture.review_state_envelope as Record).state as Record).report as { entries: []; summary: { total: 0; passed: 0; failed: 0; skipped: 0 } }, - diagnostics: ((((fixture.review_state_envelope as Record).state as Record).diagnostics ?? []) as []), - requests: ((((fixture.review_state_envelope as Record).state as Record).requests ?? []) as []), - appliedDecisions: ((((fixture.review_state_envelope as Record).state as Record).applied_decisions ?? []) as []), + report: ( + (fixture.review_state_envelope as Record).state as Record< + string, + unknown + > + ).report as { entries: []; summary: { total: 0; passed: 0; failed: 0; skipped: 0 } }, + diagnostics: (( + (fixture.review_state_envelope as Record).state as Record< + string, + unknown + > + ).diagnostics ?? []) as [], + requests: (( + (fixture.review_state_envelope as Record).state as Record< + string, + unknown + > + ).requests ?? []) as [], + appliedDecisions: (( + (fixture.review_state_envelope as Record).state as Record< + string, + unknown + > + ).applied_decisions ?? []) as [], hostHints: { - interactive: ((((fixture.review_state_envelope as Record).state as Record).host_hints as Record).interactive as boolean), - requireExplicitContexts: ((((fixture.review_state_envelope as Record).state as Record).host_hints as Record).require_explicit_contexts as boolean) + interactive: ( + ( + (fixture.review_state_envelope as Record).state as Record< + string, + unknown + > + ).host_hints as Record + ).interactive as boolean, + requireExplicitContexts: ( + ( + (fixture.review_state_envelope as Record).state as Record< + string, + unknown + > + ).host_hints as Record + ).require_explicit_contexts as boolean }, replayContext: { - surface: ((((fixture.review_state_envelope as Record).state as Record).replay_context as Record).surface as 'conformance_manifest'), - families: ((((fixture.review_state_envelope as Record).state as Record).replay_context as Record).families as string[]), - requireExplicitContexts: ((((fixture.review_state_envelope as Record).state as Record).replay_context as Record).require_explicit_contexts as boolean) + surface: ( + ( + (fixture.review_state_envelope as Record).state as Record< + string, + unknown + > + ).replay_context as Record + ).surface as 'conformance_manifest', + families: ( + ( + (fixture.review_state_envelope as Record).state as Record< + string, + unknown + > + ).replay_context as Record + ).families as string[], + requireExplicitContexts: ( + ( + (fixture.review_state_envelope as Record).state as Record< + string, + unknown + > + ).replay_context as Record + ).require_explicit_contexts as boolean }, - reviewedNestedExecutions: (((((fixture.review_state_envelope as Record).state as Record).reviewed_nested_executions ?? []) as Array>).map((execution) => ({ + reviewedNestedExecutions: ( + (( + (fixture.review_state_envelope as Record).state as Record< + string, + unknown + > + ).reviewed_nested_executions ?? []) as Array> + ).map((execution) => ({ family: execution.family as string, reviewState: { requests: ((execution.review_state as Record).requests ?? []) as [], - acceptedGroups: (((execution.review_state as Record).accepted_groups ?? []) as Array>).map((group) => ({ + acceptedGroups: ( + ((execution.review_state as Record).accepted_groups ?? + []) as Array> + ).map((group) => ({ delegatedApplyGroup: group.delegated_apply_group as string, parentOperationId: group.parent_operation_id as string, childOperationId: group.child_operation_id as string, @@ -488,17 +666,23 @@ describe('markdown-it-merge shared fixtures', () => { caseIds: group.case_ids as string[], delegatedCaseIds: group.delegated_case_ids as string[] })), - appliedDecisions: (((execution.review_state as Record).applied_decisions ?? []) as Array>).map((decision) => ({ + appliedDecisions: ( + ((execution.review_state as Record).applied_decisions ?? + []) as Array> + ).map((decision) => ({ requestId: decision.request_id as string, action: decision.action as 'apply_delegated_child_group' })), - diagnostics: ((execution.review_state as Record).diagnostics ?? []) as [] + diagnostics: ((execution.review_state as Record).diagnostics ?? + []) as [] }, - appliedChildren: (execution.applied_children as Array>).map((entry) => ({ - operationId: entry.operation_id as string, - output: entry.output as string - })) - }))) + appliedChildren: (execution.applied_children as Array>).map( + (entry) => ({ + operationId: entry.operation_id as string, + output: entry.output as string + }) + ) + })) } } ); diff --git a/packages/markdown-merge/src/contracts.ts b/packages/markdown-merge/src/contracts.ts index 7298f02..dce54fb 100644 --- a/packages/markdown-merge/src/contracts.ts +++ b/packages/markdown-merge/src/contracts.ts @@ -17,7 +17,7 @@ import type { ReviewedNestedExecution } from '@structuredmerge/ast-merge'; import { - delegatedChildApplyPlan as astDelegatedChildApplyPlan, + type delegatedChildApplyPlan as astDelegatedChildApplyPlan, executeNestedMerge, importConformanceManifestReviewStateEnvelope, importReviewReplayBundleEnvelope, @@ -314,7 +314,10 @@ function markdownOwnerStartIndices(source: string): Map { return starts; } -function collectMarkdownSections(source: string, owners: readonly MarkdownOwner[]): MarkdownSection[] { +function collectMarkdownSections( + source: string, + owners: readonly MarkdownOwner[] +): MarkdownSection[] { const normalized = normalizeMarkdownSource(source); const lines = normalized.split('\n'); const starts = markdownOwnerStartIndices(normalized); @@ -409,7 +412,9 @@ export function applyMarkdownDelegatedChildOutputs( return { ok: false, diagnostics: [ - configurationError(error instanceof Error ? error.message : 'failed to apply delegated child outputs.') + configurationError( + error instanceof Error ? error.message : 'failed to apply delegated child outputs.' + ) ], policies: [] }; @@ -526,38 +531,40 @@ export function mergeMarkdownWithReviewedNestedOutputsFromReplayBundle( }; } - return executeReviewReplayBundleReviewedNestedExecutions(replayBundle, () => ({ - mergeParent: () => mergeMarkdown(templateSource, destinationSource, dialect, backend), - discoverOperations: (mergedOutput) => { - const analysis = parseMarkdown(mergedOutput, dialect, backend); - if (!analysis.ok || !analysis.analysis) { - return { ok: false, diagnostics: analysis.diagnostics }; - } + return ( + executeReviewReplayBundleReviewedNestedExecutions(replayBundle, () => ({ + mergeParent: () => mergeMarkdown(templateSource, destinationSource, dialect, backend), + discoverOperations: (mergedOutput) => { + const analysis = parseMarkdown(mergedOutput, dialect, backend); + if (!analysis.ok || !analysis.analysis) { + return { ok: false, diagnostics: analysis.diagnostics }; + } - return { - ok: true, - diagnostics: [], - operations: markdownDelegatedChildOperations(analysis.analysis) - }; - }, - applyResolvedOutputs: (mergedOutput, operations, applyPlan, resolvedChildren) => - applyMarkdownDelegatedChildOutputs( - mergedOutput, - operations, - applyPlan, - resolvedChildren as readonly AppliedDelegatedChildOutput[] - ) - })).find((run) => run.execution.family === execution.family)?.result ?? { - ok: false, - diagnostics: [ - { - severity: 'error', - category: 'configuration_error', - message: 'review replay bundle markdown execution could not be applied.' - } - ], - policies: [] - }; + return { + ok: true, + diagnostics: [], + operations: markdownDelegatedChildOperations(analysis.analysis) + }; + }, + applyResolvedOutputs: (mergedOutput, operations, applyPlan, resolvedChildren) => + applyMarkdownDelegatedChildOutputs( + mergedOutput, + operations, + applyPlan, + resolvedChildren as readonly AppliedDelegatedChildOutput[] + ) + })).find((run) => run.execution.family === execution.family)?.result ?? { + ok: false, + diagnostics: [ + { + severity: 'error', + category: 'configuration_error', + message: 'review replay bundle markdown execution could not be applied.' + } + ], + policies: [] + } + ); } export function mergeMarkdownWithReviewedNestedOutputsFromReviewState( @@ -582,38 +589,40 @@ export function mergeMarkdownWithReviewedNestedOutputsFromReviewState( }; } - return executeReviewStateReviewedNestedExecutions(reviewState, () => ({ - mergeParent: () => mergeMarkdown(templateSource, destinationSource, dialect, backend), - discoverOperations: (mergedOutput) => { - const analysis = parseMarkdown(mergedOutput, dialect, backend); - if (!analysis.ok || !analysis.analysis) { - return { ok: false, diagnostics: analysis.diagnostics }; - } + return ( + executeReviewStateReviewedNestedExecutions(reviewState, () => ({ + mergeParent: () => mergeMarkdown(templateSource, destinationSource, dialect, backend), + discoverOperations: (mergedOutput) => { + const analysis = parseMarkdown(mergedOutput, dialect, backend); + if (!analysis.ok || !analysis.analysis) { + return { ok: false, diagnostics: analysis.diagnostics }; + } - return { - ok: true, - diagnostics: [], - operations: markdownDelegatedChildOperations(analysis.analysis) - }; - }, - applyResolvedOutputs: (mergedOutput, operations, applyPlan, resolvedChildren) => - applyMarkdownDelegatedChildOutputs( - mergedOutput, - operations, - applyPlan, - resolvedChildren as readonly AppliedDelegatedChildOutput[] - ) - })).find((run) => run.execution.family === execution.family)?.result ?? { - ok: false, - diagnostics: [ - { - severity: 'error', - category: 'configuration_error', - message: 'review state markdown execution could not be applied.' - } - ], - policies: [] - }; + return { + ok: true, + diagnostics: [], + operations: markdownDelegatedChildOperations(analysis.analysis) + }; + }, + applyResolvedOutputs: (mergedOutput, operations, applyPlan, resolvedChildren) => + applyMarkdownDelegatedChildOutputs( + mergedOutput, + operations, + applyPlan, + resolvedChildren as readonly AppliedDelegatedChildOutput[] + ) + })).find((run) => run.execution.family === execution.family)?.result ?? { + ok: false, + diagnostics: [ + { + severity: 'error', + category: 'configuration_error', + message: 'review state markdown execution could not be applied.' + } + ], + policies: [] + } + ); } export function mergeMarkdownWithReviewedNestedOutputsFromReplayBundleEnvelope( diff --git a/packages/markdown-merge/test/contracts.test.ts b/packages/markdown-merge/test/contracts.test.ts index 7dc609d..e962373 100644 --- a/packages/markdown-merge/test/contracts.test.ts +++ b/packages/markdown-merge/test/contracts.test.ts @@ -38,10 +38,7 @@ describe('markdown-merge contracts', () => { }); it('extracts headings and code fences from Markdown analysis', () => { - const result = parseMarkdown( - "# Title\n\n```ts\nconsole.log('hi')\n```\n", - 'markdown' - ); + const result = parseMarkdown("# Title\n\n```ts\nconsole.log('hi')\n```\n", 'markdown'); expect(result.ok).toBe(true); expect(result.analysis?.owners).toEqual([ { path: '/heading/0', ownerKind: 'heading', matchKey: 'h1:title', level: 1 }, diff --git a/packages/markdown-merge/test/fixtures.integration.test.ts b/packages/markdown-merge/test/fixtures.integration.test.ts index 0199160..ca2c36c 100644 --- a/packages/markdown-merge/test/fixtures.integration.test.ts +++ b/packages/markdown-merge/test/fixtures.integration.test.ts @@ -892,7 +892,10 @@ describe('markdown-merge shared fixtures', () => { case_ids: string[]; delegated_case_ids: string[]; }; - action_offers: Array<{ action: 'apply_delegated_child_group'; requires_context: boolean }>; + action_offers: Array<{ + action: 'apply_delegated_child_group'; + requires_context: boolean; + }>; default_action: 'apply_delegated_child_group'; }>; accepted_groups: Array<{ @@ -966,8 +969,15 @@ describe('markdown-merge shared fixtures', () => { template: string; destination: string; replay_bundle: { - replay_context: { surface: 'conformance_manifest'; families: string[]; require_explicit_contexts: boolean }; - decisions: Array<{ request_id: string; action: 'accept_default_context' | 'apply_delegated_child_group' }>; + replay_context: { + surface: 'conformance_manifest'; + families: string[]; + require_explicit_contexts: boolean; + }; + decisions: Array<{ + request_id: string; + action: 'accept_default_context' | 'apply_delegated_child_group'; + }>; reviewed_nested_executions: Array<{ family: string; review_state: { @@ -985,7 +995,10 @@ describe('markdown-merge shared fixtures', () => { case_ids: string[]; delegated_case_ids: string[]; }; - action_offers: Array<{ action: 'apply_delegated_child_group'; requires_context: boolean }>; + action_offers: Array<{ + action: 'apply_delegated_child_group'; + requires_context: boolean; + }>; default_action: 'apply_delegated_child_group'; }>; accepted_groups: Array<{ @@ -1008,7 +1021,11 @@ describe('markdown-merge shared fixtures', () => { requests: []; applied_decisions: []; host_hints: { interactive: boolean; require_explicit_contexts: boolean }; - replay_context: { surface: 'conformance_manifest'; families: string[]; require_explicit_contexts: boolean }; + replay_context: { + surface: 'conformance_manifest'; + families: string[]; + require_explicit_contexts: boolean; + }; reviewed_nested_executions: Array<{ family: string; review_state: { @@ -1026,7 +1043,10 @@ describe('markdown-merge shared fixtures', () => { case_ids: string[]; delegated_case_ids: string[]; }; - action_offers: Array<{ action: 'apply_delegated_child_group'; requires_context: boolean }>; + action_offers: Array<{ + action: 'apply_delegated_child_group'; + requires_context: boolean; + }>; default_action: 'apply_delegated_child_group'; }>; accepted_groups: Array<{ @@ -1064,48 +1084,51 @@ describe('markdown-merge shared fixtures', () => { requestId: decision.request_id, action: decision.action })), - reviewedNestedExecutions: fixture.replay_bundle.reviewed_nested_executions.map((execution) => ({ - family: execution.family, - reviewState: { - requests: execution.review_state.requests.map((request) => ({ - id: request.id, - kind: request.kind, - family: request.family, - message: request.message, - blocking: request.blocking, - delegatedGroup: { - delegatedApplyGroup: request.delegated_group.delegated_apply_group, - parentOperationId: request.delegated_group.parent_operation_id, - childOperationId: request.delegated_group.child_operation_id, - delegatedRuntimeSurfacePath: request.delegated_group.delegated_runtime_surface_path, - caseIds: request.delegated_group.case_ids, - delegatedCaseIds: request.delegated_group.delegated_case_ids - }, - actionOffers: request.action_offers.map((offer) => ({ - action: offer.action, - requiresContext: offer.requires_context + reviewedNestedExecutions: fixture.replay_bundle.reviewed_nested_executions.map( + (execution) => ({ + family: execution.family, + reviewState: { + requests: execution.review_state.requests.map((request) => ({ + id: request.id, + kind: request.kind, + family: request.family, + message: request.message, + blocking: request.blocking, + delegatedGroup: { + delegatedApplyGroup: request.delegated_group.delegated_apply_group, + parentOperationId: request.delegated_group.parent_operation_id, + childOperationId: request.delegated_group.child_operation_id, + delegatedRuntimeSurfacePath: + request.delegated_group.delegated_runtime_surface_path, + caseIds: request.delegated_group.case_ids, + delegatedCaseIds: request.delegated_group.delegated_case_ids + }, + actionOffers: request.action_offers.map((offer) => ({ + action: offer.action, + requiresContext: offer.requires_context + })), + defaultAction: request.default_action })), - defaultAction: request.default_action - })), - acceptedGroups: execution.review_state.accepted_groups.map((group) => ({ - delegatedApplyGroup: group.delegated_apply_group, - parentOperationId: group.parent_operation_id, - childOperationId: group.child_operation_id, - delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path, - caseIds: group.case_ids, - delegatedCaseIds: group.delegated_case_ids - })), - appliedDecisions: execution.review_state.applied_decisions.map((decision) => ({ - requestId: decision.request_id, - action: decision.action - })), - diagnostics: execution.review_state.diagnostics - }, - appliedChildren: execution.applied_children.map((entry) => ({ - operationId: entry.operation_id, - output: entry.output - })) - })) + acceptedGroups: execution.review_state.accepted_groups.map((group) => ({ + delegatedApplyGroup: group.delegated_apply_group, + parentOperationId: group.parent_operation_id, + childOperationId: group.child_operation_id, + delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path, + caseIds: group.case_ids, + delegatedCaseIds: group.delegated_case_ids + })), + appliedDecisions: execution.review_state.applied_decisions.map((decision) => ({ + requestId: decision.request_id, + action: decision.action + })), + diagnostics: execution.review_state.diagnostics + }, + appliedChildren: execution.applied_children.map((entry) => ({ + operationId: entry.operation_id, + output: entry.output + })) + }) + ) } ); @@ -1127,48 +1150,51 @@ describe('markdown-merge shared fixtures', () => { families: fixture.review_state.replay_context.families, requireExplicitContexts: fixture.review_state.replay_context.require_explicit_contexts }, - reviewedNestedExecutions: fixture.review_state.reviewed_nested_executions.map((execution) => ({ - family: execution.family, - reviewState: { - requests: execution.review_state.requests.map((request) => ({ - id: request.id, - kind: request.kind, - family: request.family, - message: request.message, - blocking: request.blocking, - delegatedGroup: { - delegatedApplyGroup: request.delegated_group.delegated_apply_group, - parentOperationId: request.delegated_group.parent_operation_id, - childOperationId: request.delegated_group.child_operation_id, - delegatedRuntimeSurfacePath: request.delegated_group.delegated_runtime_surface_path, - caseIds: request.delegated_group.case_ids, - delegatedCaseIds: request.delegated_group.delegated_case_ids - }, - actionOffers: request.action_offers.map((offer) => ({ - action: offer.action, - requiresContext: offer.requires_context + reviewedNestedExecutions: fixture.review_state.reviewed_nested_executions.map( + (execution) => ({ + family: execution.family, + reviewState: { + requests: execution.review_state.requests.map((request) => ({ + id: request.id, + kind: request.kind, + family: request.family, + message: request.message, + blocking: request.blocking, + delegatedGroup: { + delegatedApplyGroup: request.delegated_group.delegated_apply_group, + parentOperationId: request.delegated_group.parent_operation_id, + childOperationId: request.delegated_group.child_operation_id, + delegatedRuntimeSurfacePath: + request.delegated_group.delegated_runtime_surface_path, + caseIds: request.delegated_group.case_ids, + delegatedCaseIds: request.delegated_group.delegated_case_ids + }, + actionOffers: request.action_offers.map((offer) => ({ + action: offer.action, + requiresContext: offer.requires_context + })), + defaultAction: request.default_action })), - defaultAction: request.default_action - })), - acceptedGroups: execution.review_state.accepted_groups.map((group) => ({ - delegatedApplyGroup: group.delegated_apply_group, - parentOperationId: group.parent_operation_id, - childOperationId: group.child_operation_id, - delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path, - caseIds: group.case_ids, - delegatedCaseIds: group.delegated_case_ids - })), - appliedDecisions: execution.review_state.applied_decisions.map((decision) => ({ - requestId: decision.request_id, - action: decision.action - })), - diagnostics: execution.review_state.diagnostics - }, - appliedChildren: execution.applied_children.map((entry) => ({ - operationId: entry.operation_id, - output: entry.output - })) - })) + acceptedGroups: execution.review_state.accepted_groups.map((group) => ({ + delegatedApplyGroup: group.delegated_apply_group, + parentOperationId: group.parent_operation_id, + childOperationId: group.child_operation_id, + delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path, + caseIds: group.case_ids, + delegatedCaseIds: group.delegated_case_ids + })), + appliedDecisions: execution.review_state.applied_decisions.map((decision) => ({ + requestId: decision.request_id, + action: decision.action + })), + diagnostics: execution.review_state.diagnostics + }, + appliedChildren: execution.applied_children.map((entry) => ({ + operationId: entry.operation_id, + output: entry.output + })) + }) + ) } ); @@ -1223,8 +1249,15 @@ describe('markdown-merge shared fixtures', () => { kind: 'review_replay_bundle'; version: 1; replay_bundle: { - replay_context: { surface: 'conformance_manifest'; families: string[]; require_explicit_contexts: boolean }; - decisions: Array<{ request_id: string; action: 'accept_default_context' | 'apply_delegated_child_group' }>; + replay_context: { + surface: 'conformance_manifest'; + families: string[]; + require_explicit_contexts: boolean; + }; + decisions: Array<{ + request_id: string; + action: 'accept_default_context' | 'apply_delegated_child_group'; + }>; reviewed_nested_executions: Array<{ family: string; review_state: { @@ -1242,11 +1275,17 @@ describe('markdown-merge shared fixtures', () => { case_ids: string[]; delegated_case_ids: string[]; }; - action_offers: Array<{ action: 'apply_delegated_child_group'; requires_context: boolean }>; + action_offers: Array<{ + action: 'apply_delegated_child_group'; + requires_context: boolean; + }>; default_action: 'apply_delegated_child_group'; }>; accepted_groups: Array; - applied_decisions: Array<{ request_id: string; action: 'apply_delegated_child_group' }>; + applied_decisions: Array<{ + request_id: string; + action: 'apply_delegated_child_group'; + }>; diagnostics: []; }; applied_children: Array<{ operation_id: string; output: string }>; @@ -1262,7 +1301,11 @@ describe('markdown-merge shared fixtures', () => { requests: []; applied_decisions: []; host_hints: { interactive: boolean; require_explicit_contexts: boolean }; - replay_context: { surface: 'conformance_manifest'; families: string[]; require_explicit_contexts: boolean }; + replay_context: { + surface: 'conformance_manifest'; + families: string[]; + require_explicit_contexts: boolean; + }; reviewed_nested_executions: Array<{ family: string; review_state: { @@ -1280,11 +1323,17 @@ describe('markdown-merge shared fixtures', () => { case_ids: string[]; delegated_case_ids: string[]; }; - action_offers: Array<{ action: 'apply_delegated_child_group'; requires_context: boolean }>; + action_offers: Array<{ + action: 'apply_delegated_child_group'; + requires_context: boolean; + }>; default_action: 'apply_delegated_child_group'; }>; accepted_groups: Array; - applied_decisions: Array<{ request_id: string; action: 'apply_delegated_child_group' }>; + applied_decisions: Array<{ + request_id: string; + action: 'apply_delegated_child_group'; + }>; diagnostics: []; }; applied_children: Array<{ operation_id: string; output: string }>; @@ -1312,48 +1361,52 @@ describe('markdown-merge shared fixtures', () => { requestId: decision.request_id, action: decision.action })), - reviewedNestedExecutions: fixture.replay_bundle_envelope.replay_bundle.reviewed_nested_executions.map((execution) => ({ - family: execution.family, - reviewState: { - requests: execution.review_state.requests.map((request) => ({ - id: request.id, - kind: request.kind, - family: request.family, - message: request.message, - blocking: request.blocking, - delegatedGroup: { - delegatedApplyGroup: request.delegated_group.delegated_apply_group, - parentOperationId: request.delegated_group.parent_operation_id, - childOperationId: request.delegated_group.child_operation_id, - delegatedRuntimeSurfacePath: request.delegated_group.delegated_runtime_surface_path, - caseIds: request.delegated_group.case_ids, - delegatedCaseIds: request.delegated_group.delegated_case_ids + reviewedNestedExecutions: + fixture.replay_bundle_envelope.replay_bundle.reviewed_nested_executions.map( + (execution) => ({ + family: execution.family, + reviewState: { + requests: execution.review_state.requests.map((request) => ({ + id: request.id, + kind: request.kind, + family: request.family, + message: request.message, + blocking: request.blocking, + delegatedGroup: { + delegatedApplyGroup: request.delegated_group.delegated_apply_group, + parentOperationId: request.delegated_group.parent_operation_id, + childOperationId: request.delegated_group.child_operation_id, + delegatedRuntimeSurfacePath: + request.delegated_group.delegated_runtime_surface_path, + caseIds: request.delegated_group.case_ids, + delegatedCaseIds: request.delegated_group.delegated_case_ids + }, + actionOffers: request.action_offers.map((offer) => ({ + action: offer.action, + requiresContext: offer.requires_context + })), + defaultAction: request.default_action + })), + acceptedGroups: execution.review_state.accepted_groups.map((group) => ({ + delegatedApplyGroup: group.delegated_apply_group, + parentOperationId: group.parent_operation_id, + childOperationId: group.child_operation_id, + delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path, + caseIds: group.case_ids, + delegatedCaseIds: group.delegated_case_ids + })), + appliedDecisions: execution.review_state.applied_decisions.map((decision) => ({ + requestId: decision.request_id, + action: decision.action + })), + diagnostics: execution.review_state.diagnostics }, - actionOffers: request.action_offers.map((offer) => ({ - action: offer.action, - requiresContext: offer.requires_context - })), - defaultAction: request.default_action - })), - acceptedGroups: execution.review_state.accepted_groups.map((group) => ({ - delegatedApplyGroup: group.delegated_apply_group, - parentOperationId: group.parent_operation_id, - childOperationId: group.child_operation_id, - delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path, - caseIds: group.case_ids, - delegatedCaseIds: group.delegated_case_ids - })), - appliedDecisions: execution.review_state.applied_decisions.map((decision) => ({ - requestId: decision.request_id, - action: decision.action - })), - diagnostics: execution.review_state.diagnostics - }, - appliedChildren: execution.applied_children.map((entry) => ({ - operationId: entry.operation_id, - output: entry.output - })) - })) + appliedChildren: execution.applied_children.map((entry) => ({ + operationId: entry.operation_id, + output: entry.output + })) + }) + ) } }; @@ -1430,7 +1483,12 @@ describe('markdown-merge shared fixtures', () => { 'markdown', replayBundleEnvelope ) - ).toEqual({ ok: fixture.expected.ok, output: fixture.expected.output, diagnostics: [], policies: [] }); + ).toEqual({ + ok: fixture.expected.ok, + output: fixture.expected.output, + diagnostics: [], + policies: [] + }); expect( mergeMarkdownWithReviewedNestedOutputsFromReviewStateEnvelope( @@ -1439,7 +1497,12 @@ describe('markdown-merge shared fixtures', () => { 'markdown', reviewStateEnvelope ) - ).toEqual({ ok: fixture.expected.ok, output: fixture.expected.output, diagnostics: [], policies: [] }); + ).toEqual({ + ok: fixture.expected.ok, + output: fixture.expected.output, + diagnostics: [], + policies: [] + }); }); it('conforms to the slice-315 reviewed nested review artifact envelope rejection fixture', () => { diff --git a/packages/peggy-toml-merge/test/fixtures.integration.test.ts b/packages/peggy-toml-merge/test/fixtures.integration.test.ts index 37d7f71..f2710eb 100644 --- a/packages/peggy-toml-merge/test/fixtures.integration.test.ts +++ b/packages/peggy-toml-merge/test/fixtures.integration.test.ts @@ -51,8 +51,9 @@ describe('peggy-toml-merge shared fixtures', () => { supportedDialects: providerProfile.supported_dialects, supportedPolicies: providerProfile.supported_policies, backend: providerProfile.backend, - backendRef: (providerProfile.backendRef ?? - providerProfile.backend_ref) as Record | undefined + backendRef: (providerProfile.backendRef ?? providerProfile.backend_ref) as + | Record + | undefined }); }); diff --git a/packages/ruby-merge/src/contracts.ts b/packages/ruby-merge/src/contracts.ts index 148e8e1..9ec49d4 100644 --- a/packages/ruby-merge/src/contracts.ts +++ b/packages/ruby-merge/src/contracts.ts @@ -16,7 +16,7 @@ import type { ReviewedNestedExecution } from '@structuredmerge/ast-merge'; import { - delegatedChildApplyPlan as astDelegatedChildApplyPlan, + type delegatedChildApplyPlan as astDelegatedChildApplyPlan, executeNestedMerge, importConformanceManifestReviewStateEnvelope, importReviewReplayBundleEnvelope, @@ -279,7 +279,9 @@ export function applyRubyDelegatedChildOutputs( return { ok: false, diagnostics: [ - configurationError(error instanceof Error ? error.message : 'failed to apply delegated child outputs.') + configurationError( + error instanceof Error ? error.message : 'failed to apply delegated child outputs.' + ) ], policies: [] }; @@ -393,38 +395,40 @@ export function mergeRubyWithReviewedNestedOutputsFromReplayBundle( }; } - return executeReviewReplayBundleReviewedNestedExecutions(replayBundle, () => ({ - mergeParent: () => mergeRuby(templateSource, destinationSource, dialect), - discoverOperations: (mergedOutput) => { - const analysis = parseRuby(mergedOutput, dialect); - if (!analysis.ok || !analysis.analysis) { - return { ok: false, diagnostics: analysis.diagnostics }; - } + return ( + executeReviewReplayBundleReviewedNestedExecutions(replayBundle, () => ({ + mergeParent: () => mergeRuby(templateSource, destinationSource, dialect), + discoverOperations: (mergedOutput) => { + const analysis = parseRuby(mergedOutput, dialect); + if (!analysis.ok || !analysis.analysis) { + return { ok: false, diagnostics: analysis.diagnostics }; + } - return { - ok: true, - diagnostics: [], - operations: rubyDelegatedChildOperations(analysis.analysis) - }; - }, - applyResolvedOutputs: (mergedOutput, operations, applyPlan, resolvedChildren) => - applyRubyDelegatedChildOutputs( - mergedOutput, - operations, - applyPlan, - resolvedChildren as readonly AppliedDelegatedChildOutput[] - ) - })).find((run) => run.execution.family === execution.family)?.result ?? { - ok: false, - diagnostics: [ - { - severity: 'error', - category: 'configuration_error', - message: 'review replay bundle ruby execution could not be applied.' - } - ], - policies: [] - }; + return { + ok: true, + diagnostics: [], + operations: rubyDelegatedChildOperations(analysis.analysis) + }; + }, + applyResolvedOutputs: (mergedOutput, operations, applyPlan, resolvedChildren) => + applyRubyDelegatedChildOutputs( + mergedOutput, + operations, + applyPlan, + resolvedChildren as readonly AppliedDelegatedChildOutput[] + ) + })).find((run) => run.execution.family === execution.family)?.result ?? { + ok: false, + diagnostics: [ + { + severity: 'error', + category: 'configuration_error', + message: 'review replay bundle ruby execution could not be applied.' + } + ], + policies: [] + } + ); } export function mergeRubyWithReviewedNestedOutputsFromReviewState( @@ -448,38 +452,40 @@ export function mergeRubyWithReviewedNestedOutputsFromReviewState( }; } - return executeReviewStateReviewedNestedExecutions(reviewState, () => ({ - mergeParent: () => mergeRuby(templateSource, destinationSource, dialect), - discoverOperations: (mergedOutput) => { - const analysis = parseRuby(mergedOutput, dialect); - if (!analysis.ok || !analysis.analysis) { - return { ok: false, diagnostics: analysis.diagnostics }; - } + return ( + executeReviewStateReviewedNestedExecutions(reviewState, () => ({ + mergeParent: () => mergeRuby(templateSource, destinationSource, dialect), + discoverOperations: (mergedOutput) => { + const analysis = parseRuby(mergedOutput, dialect); + if (!analysis.ok || !analysis.analysis) { + return { ok: false, diagnostics: analysis.diagnostics }; + } - return { - ok: true, - diagnostics: [], - operations: rubyDelegatedChildOperations(analysis.analysis) - }; - }, - applyResolvedOutputs: (mergedOutput, operations, applyPlan, resolvedChildren) => - applyRubyDelegatedChildOutputs( - mergedOutput, - operations, - applyPlan, - resolvedChildren as readonly AppliedDelegatedChildOutput[] - ) - })).find((run) => run.execution.family === execution.family)?.result ?? { - ok: false, - diagnostics: [ - { - severity: 'error', - category: 'configuration_error', - message: 'review state ruby execution could not be applied.' - } - ], - policies: [] - }; + return { + ok: true, + diagnostics: [], + operations: rubyDelegatedChildOperations(analysis.analysis) + }; + }, + applyResolvedOutputs: (mergedOutput, operations, applyPlan, resolvedChildren) => + applyRubyDelegatedChildOutputs( + mergedOutput, + operations, + applyPlan, + resolvedChildren as readonly AppliedDelegatedChildOutput[] + ) + })).find((run) => run.execution.family === execution.family)?.result ?? { + ok: false, + diagnostics: [ + { + severity: 'error', + category: 'configuration_error', + message: 'review state ruby execution could not be applied.' + } + ], + policies: [] + } + ); } export function mergeRubyWithReviewedNestedOutputsFromReplayBundleEnvelope( @@ -771,11 +777,12 @@ export function mergeRuby( const destinationRequires = collectRubyRequireEntries(destination.analysis.source); const destinationDeclarations = collectRubyDeclarationEntries(destination.analysis.source); const templateDeclarations = collectRubyDeclarationEntries(template.analysis.source); - const destinationDeclarationPaths = new Set( - destinationDeclarations.map((entry) => entry.path) - ); + const destinationDeclarationPaths = new Set(destinationDeclarations.map((entry) => entry.path)); const sections = [ - destinationRequires.map((entry) => entry.text).join('\n').trim(), + destinationRequires + .map((entry) => entry.text) + .join('\n') + .trim(), ...destinationDeclarations.map((entry) => entry.text), ...templateDeclarations .filter((entry) => !destinationDeclarationPaths.has(entry.path)) diff --git a/packages/ruby-merge/test/fixtures.integration.test.ts b/packages/ruby-merge/test/fixtures.integration.test.ts index aaff283..ccc39c3 100644 --- a/packages/ruby-merge/test/fixtures.integration.test.ts +++ b/packages/ruby-merge/test/fixtures.integration.test.ts @@ -334,9 +334,7 @@ describe('ruby-merge shared fixtures', () => { 'ruby' ); expect(invalidDestinationResult.ok).toBe(false); - expect( - invalidDestinationResult.diagnostics.map((diagnostic) => diagnostic.category) - ).toEqual( + expect(invalidDestinationResult.diagnostics.map((diagnostic) => diagnostic.category)).toEqual( invalidDestinationFixture.expected.diagnostics.map((diagnostic) => diagnostic.category) ); @@ -771,7 +769,10 @@ describe('ruby-merge shared fixtures', () => { case_ids: string[]; delegated_case_ids: string[]; }; - action_offers: Array<{ action: 'apply_delegated_child_group'; requires_context: boolean }>; + action_offers: Array<{ + action: 'apply_delegated_child_group'; + requires_context: boolean; + }>; default_action: 'apply_delegated_child_group'; }>; accepted_groups: Array<{ @@ -845,7 +846,11 @@ describe('ruby-merge shared fixtures', () => { template: string; destination: string; replay_bundle: { - replay_context: { surface: 'conformance_manifest'; families: string[]; require_explicit_contexts: boolean }; + replay_context: { + surface: 'conformance_manifest'; + families: string[]; + require_explicit_contexts: boolean; + }; decisions: []; reviewed_nested_executions: Array<{ family: string; @@ -871,7 +876,11 @@ describe('ruby-merge shared fixtures', () => { requests: []; applied_decisions: []; host_hints: { interactive: boolean; require_explicit_contexts: boolean }; - replay_context: { surface: 'conformance_manifest'; families: string[]; require_explicit_contexts: boolean }; + replay_context: { + surface: 'conformance_manifest'; + families: string[]; + require_explicit_contexts: boolean; + }; reviewed_nested_executions: Array<{ family: string; review_state: { @@ -908,29 +917,31 @@ describe('ruby-merge shared fixtures', () => { requireExplicitContexts: fixture.replay_bundle.replay_context.require_explicit_contexts }, decisions: fixture.replay_bundle.decisions, - reviewedNestedExecutions: fixture.replay_bundle.reviewed_nested_executions.map((execution) => ({ - family: execution.family, - reviewState: { - requests: execution.review_state.requests, - acceptedGroups: execution.review_state.accepted_groups.map((group) => ({ - delegatedApplyGroup: group.delegated_apply_group, - parentOperationId: group.parent_operation_id, - childOperationId: group.child_operation_id, - delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path, - caseIds: group.case_ids, - delegatedCaseIds: group.delegated_case_ids - })), - appliedDecisions: execution.review_state.applied_decisions.map((decision) => ({ - requestId: decision.request_id, - action: decision.action - })), - diagnostics: execution.review_state.diagnostics - }, - appliedChildren: execution.applied_children.map((entry) => ({ - operationId: entry.operation_id, - output: entry.output - })) - })) + reviewedNestedExecutions: fixture.replay_bundle.reviewed_nested_executions.map( + (execution) => ({ + family: execution.family, + reviewState: { + requests: execution.review_state.requests, + acceptedGroups: execution.review_state.accepted_groups.map((group) => ({ + delegatedApplyGroup: group.delegated_apply_group, + parentOperationId: group.parent_operation_id, + childOperationId: group.child_operation_id, + delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path, + caseIds: group.case_ids, + delegatedCaseIds: group.delegated_case_ids + })), + appliedDecisions: execution.review_state.applied_decisions.map((decision) => ({ + requestId: decision.request_id, + action: decision.action + })), + diagnostics: execution.review_state.diagnostics + }, + appliedChildren: execution.applied_children.map((entry) => ({ + operationId: entry.operation_id, + output: entry.output + })) + }) + ) } ); @@ -952,29 +963,31 @@ describe('ruby-merge shared fixtures', () => { families: fixture.review_state.replay_context.families, requireExplicitContexts: fixture.review_state.replay_context.require_explicit_contexts }, - reviewedNestedExecutions: fixture.review_state.reviewed_nested_executions.map((execution) => ({ - family: execution.family, - reviewState: { - requests: execution.review_state.requests, - acceptedGroups: execution.review_state.accepted_groups.map((group) => ({ - delegatedApplyGroup: group.delegated_apply_group, - parentOperationId: group.parent_operation_id, - childOperationId: group.child_operation_id, - delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path, - caseIds: group.case_ids, - delegatedCaseIds: group.delegated_case_ids - })), - appliedDecisions: execution.review_state.applied_decisions.map((decision) => ({ - requestId: decision.request_id, - action: decision.action - })), - diagnostics: execution.review_state.diagnostics - }, - appliedChildren: execution.applied_children.map((entry) => ({ - operationId: entry.operation_id, - output: entry.output - })) - })) + reviewedNestedExecutions: fixture.review_state.reviewed_nested_executions.map( + (execution) => ({ + family: execution.family, + reviewState: { + requests: execution.review_state.requests, + acceptedGroups: execution.review_state.accepted_groups.map((group) => ({ + delegatedApplyGroup: group.delegated_apply_group, + parentOperationId: group.parent_operation_id, + childOperationId: group.child_operation_id, + delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path, + caseIds: group.case_ids, + delegatedCaseIds: group.delegated_case_ids + })), + appliedDecisions: execution.review_state.applied_decisions.map((decision) => ({ + requestId: decision.request_id, + action: decision.action + })), + diagnostics: execution.review_state.diagnostics + }, + appliedChildren: execution.applied_children.map((entry) => ({ + operationId: entry.operation_id, + output: entry.output + })) + }) + ) } ); @@ -1029,14 +1042,21 @@ describe('ruby-merge shared fixtures', () => { kind: 'review_replay_bundle'; version: 1; replay_bundle: { - replay_context: { surface: 'conformance_manifest'; families: string[]; require_explicit_contexts: boolean }; + replay_context: { + surface: 'conformance_manifest'; + families: string[]; + require_explicit_contexts: boolean; + }; decisions: []; reviewed_nested_executions: Array<{ family: string; review_state: { requests: []; accepted_groups: Array; - applied_decisions: Array<{ request_id: string; action: 'apply_delegated_child_group' }>; + applied_decisions: Array<{ + request_id: string; + action: 'apply_delegated_child_group'; + }>; diagnostics: []; }; applied_children: Array<{ operation_id: string; output: string }>; @@ -1052,13 +1072,20 @@ describe('ruby-merge shared fixtures', () => { requests: []; applied_decisions: []; host_hints: { interactive: boolean; require_explicit_contexts: boolean }; - replay_context: { surface: 'conformance_manifest'; families: string[]; require_explicit_contexts: boolean }; + replay_context: { + surface: 'conformance_manifest'; + families: string[]; + require_explicit_contexts: boolean; + }; reviewed_nested_executions: Array<{ family: string; review_state: { requests: []; accepted_groups: Array; - applied_decisions: Array<{ request_id: string; action: 'apply_delegated_child_group' }>; + applied_decisions: Array<{ + request_id: string; + action: 'apply_delegated_child_group'; + }>; diagnostics: []; }; applied_children: Array<{ operation_id: string; output: string }>; @@ -1084,29 +1111,31 @@ describe('ruby-merge shared fixtures', () => { }, decisions: fixture.replay_bundle_envelope.replay_bundle.decisions, reviewedNestedExecutions: - fixture.replay_bundle_envelope.replay_bundle.reviewed_nested_executions.map((execution) => ({ - family: execution.family, - reviewState: { - requests: execution.review_state.requests, - acceptedGroups: execution.review_state.accepted_groups.map((group) => ({ - delegatedApplyGroup: group.delegated_apply_group, - parentOperationId: group.parent_operation_id, - childOperationId: group.child_operation_id, - delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path, - caseIds: group.case_ids, - delegatedCaseIds: group.delegated_case_ids - })), - appliedDecisions: execution.review_state.applied_decisions.map((decision) => ({ - requestId: decision.request_id, - action: decision.action - })), - diagnostics: execution.review_state.diagnostics - }, - appliedChildren: execution.applied_children.map((entry) => ({ - operationId: entry.operation_id, - output: entry.output - })) - })) + fixture.replay_bundle_envelope.replay_bundle.reviewed_nested_executions.map( + (execution) => ({ + family: execution.family, + reviewState: { + requests: execution.review_state.requests, + acceptedGroups: execution.review_state.accepted_groups.map((group) => ({ + delegatedApplyGroup: group.delegated_apply_group, + parentOperationId: group.parent_operation_id, + childOperationId: group.child_operation_id, + delegatedRuntimeSurfacePath: group.delegated_runtime_surface_path, + caseIds: group.case_ids, + delegatedCaseIds: group.delegated_case_ids + })), + appliedDecisions: execution.review_state.applied_decisions.map((decision) => ({ + requestId: decision.request_id, + action: decision.action + })), + diagnostics: execution.review_state.diagnostics + }, + appliedChildren: execution.applied_children.map((entry) => ({ + operationId: entry.operation_id, + output: entry.output + })) + }) + ) } }; @@ -1241,11 +1270,7 @@ describe('ruby-merge shared fixtures', () => { supported_policies: Array<{ surface: 'array'; name: string }>; }; }; - }>( - 'diagnostics', - 'slice-216-ruby-family-plan-contexts', - 'ruby-ruby-plan-contexts.json' - ); + }>('diagnostics', 'slice-216-ruby-family-plan-contexts', 'ruby-ruby-plan-contexts.json'); expect(availableRubyBackends()).toEqual(['kreuzberg-language-pack']); expect(registeredBackends()).toContainEqual({ diff --git a/packages/typescript-compiler-merge/test/contracts.test.ts b/packages/typescript-compiler-merge/test/contracts.test.ts index 0157e97..5334ede 100644 --- a/packages/typescript-compiler-merge/test/contracts.test.ts +++ b/packages/typescript-compiler-merge/test/contracts.test.ts @@ -33,7 +33,9 @@ describe('typescript-compiler-merge contracts', () => { }); it('rejects unsupported backend overrides', () => { - expect(parseTypeScript('export const answer = 42;\n', 'typescript', 'kreuzberg-language-pack')).toEqual({ + expect( + parseTypeScript('export const answer = 42;\n', 'typescript', 'kreuzberg-language-pack') + ).toEqual({ ok: false, diagnostics: [ { @@ -44,7 +46,14 @@ describe('typescript-compiler-merge contracts', () => { ] }); - expect(mergeTypeScript('export const a = 1;\n', 'export const b = 2;\n', 'typescript', 'kreuzberg-language-pack')).toEqual({ + expect( + mergeTypeScript( + 'export const a = 1;\n', + 'export const b = 2;\n', + 'typescript', + 'kreuzberg-language-pack' + ) + ).toEqual({ ok: false, diagnostics: [ { diff --git a/packages/typescript-compiler-merge/test/fixtures.integration.test.ts b/packages/typescript-compiler-merge/test/fixtures.integration.test.ts index 6f1094f..8a965cd 100644 --- a/packages/typescript-compiler-merge/test/fixtures.integration.test.ts +++ b/packages/typescript-compiler-merge/test/fixtures.integration.test.ts @@ -78,7 +78,9 @@ describe('typescript-compiler-merge shared fixtures', () => { ); expect(availableTypeScriptBackends()).toEqual(['typescript-compiler']); - expect(typeScriptFeatureProfile()).toEqual(normalizeFixtureValue(familyFixture.feature_profile)); + expect(typeScriptFeatureProfile()).toEqual( + normalizeFixtureValue(familyFixture.feature_profile) + ); expect(typeScriptBackendFeatureProfile()).toEqual( normalizeFixtureValue(featureFixture.providers.typescript_compiler.feature_profile) ); @@ -122,12 +124,21 @@ describe('typescript-compiler-merge shared fixtures', () => { const destination = parseTypeScript(matchingFixture.destination, 'typescript'); const matchResult = matchTypeScriptOwners(template.analysis!, destination.analysis!); expect( - matchResult.matched.map(({ templatePath, destinationPath }) => [templatePath, destinationPath]) + matchResult.matched.map(({ templatePath, destinationPath }) => [ + templatePath, + destinationPath + ]) ).toEqual(matchingFixture.expected.matched); expect(matchResult.unmatchedTemplate).toEqual(matchingFixture.expected.unmatched_template); - expect(matchResult.unmatchedDestination).toEqual(matchingFixture.expected.unmatched_destination); + expect(matchResult.unmatchedDestination).toEqual( + matchingFixture.expected.unmatched_destination + ); - const mergeResult = mergeTypeScript(mergeFixture.template, mergeFixture.destination, 'typescript'); + const mergeResult = mergeTypeScript( + mergeFixture.template, + mergeFixture.destination, + 'typescript' + ); expect(mergeResult.ok).toBe(mergeFixture.expected.ok); expect(mergeResult.output).toBe(mergeFixture.expected.output); }); @@ -183,25 +194,27 @@ describe('typescript-compiler-merge shared fixtures', () => { expect( planNamedConformanceSuites( plansFixture.manifest, - normalizeFixtureValue( - plansFixture.contexts.typescript_compiler - ) as Readonly> + normalizeFixtureValue(plansFixture.contexts.typescript_compiler) as Readonly< + Record + > ) ).toEqual(normalizeFixtureValue(plansFixture.expected_entries.typescript_compiler)); const entries = reportPlannedNamedConformanceSuites( planNamedConformanceSuites( reportFixture.manifest, - normalizeFixtureValue( - reportFixture.options.typescript_compiler.contexts - ) as Readonly> + normalizeFixtureValue(reportFixture.options.typescript_compiler.contexts) as Readonly< + Record + > ), (run) => { const key = `${run.ref.family}:${run.ref.role}:${run.ref.case}`; - return reportFixture.executions.typescript_compiler[key] ?? { - outcome: 'failed', - messages: ['missing execution'] - }; + return ( + reportFixture.executions.typescript_compiler[key] ?? { + outcome: 'failed', + messages: ['missing execution'] + } + ); } ); diff --git a/packages/typescript-merge/src/contracts.ts b/packages/typescript-merge/src/contracts.ts index 6dd2b7d..b086385 100644 --- a/packages/typescript-merge/src/contracts.ts +++ b/packages/typescript-merge/src/contracts.ts @@ -8,7 +8,6 @@ import type { } from '@structuredmerge/ast-merge'; import { KREUZBERG_LANGUAGE_PACK_BACKEND, - languagePackAdapterInfo, parseWithLanguagePack, processWithLanguagePack, type BackendReference, diff --git a/packages/yaml-merge/src/contracts.ts b/packages/yaml-merge/src/contracts.ts index 57f0f45..e64fbf0 100644 --- a/packages/yaml-merge/src/contracts.ts +++ b/packages/yaml-merge/src/contracts.ts @@ -165,9 +165,7 @@ function validateYamlNode( if (value && typeof value === 'object' && !Array.isArray(value)) { const mapping: YamlMapping = {}; - for (const key of Object.keys(value as Record).sort((left, right) => - left.localeCompare(right) - )) { + for (const key of Object.keys(value as Record)) { const nextPath = `${path}/${key}`; const validated = validateYamlNode((value as Record)[key], nextPath); if (!validated.ok) { @@ -216,7 +214,7 @@ function renderYamlNode(key: string, value: YamlNode, indent: number): string[] function renderYamlMapping(mapping: YamlMapping, indent = 0): string[] { const lines: string[] = []; - for (const key of Object.keys(mapping).sort((left, right) => left.localeCompare(right))) { + for (const key of Object.keys(mapping)) { lines.push(...renderYamlNode(key, mapping[key], indent)); } @@ -256,9 +254,12 @@ function collectYamlOwners(mapping: YamlMapping, prefix = ''): YamlOwner[] { function mergeYamlMappings(template: YamlMapping, destination: YamlMapping): YamlMapping { const merged: YamlMapping = {}; - const keys = new Set([...Object.keys(template), ...Object.keys(destination)]); + const keys = [ + ...Object.keys(template), + ...Object.keys(destination).filter((key) => !(key in template)) + ]; - for (const key of Array.from(keys).sort((left, right) => left.localeCompare(right))) { + for (const key of keys) { const templateValue = template[key]; const destinationValue = destination[key]; diff --git a/packages/yaml-merge/test/contracts.test.ts b/packages/yaml-merge/test/contracts.test.ts index 53f3a37..51c1cb8 100644 --- a/packages/yaml-merge/test/contracts.test.ts +++ b/packages/yaml-merge/test/contracts.test.ts @@ -74,7 +74,7 @@ describe('yaml-merge', () => { expect(result.ok).toBe(true); expect(result.output).toBe( - 'package:\n meta:\n authors:\n - pb\n enabled: false\n release: true\n name: structuredmerge\n tags:\n - destination\n version: 0.2.0\ntitle: "Structured Merge"\n' + 'title: "Structured Merge"\npackage:\n name: structuredmerge\n tags:\n - destination\n version: 0.2.0\n meta:\n enabled: false\n authors:\n - pb\n release: true\n' ); expect(result.policies).toEqual([{ surface: 'array', name: 'destination_wins_array' }]); }); diff --git a/packages/yaml-merge/test/fixtures.integration.test.ts b/packages/yaml-merge/test/fixtures.integration.test.ts index 967bd7c..162ec42 100644 --- a/packages/yaml-merge/test/fixtures.integration.test.ts +++ b/packages/yaml-merge/test/fixtures.integration.test.ts @@ -34,7 +34,10 @@ function normalizeFixtureValue(value: T): T { } if (value && typeof value === 'object') { return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [toCamelCaseKey(key), normalizeFixtureValue(entry)]) + Object.entries(value).map(([key, entry]) => [ + toCamelCaseKey(key), + normalizeFixtureValue(entry) + ]) ) as T; } return value; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a20734..4c1fc12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,7 +39,26 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/node@24.12.2)(yaml@2.8.3) - packages/ast-merge: {} + packages/ast-merge: + devDependencies: + '@structuredmerge/markdown-merge': + specifier: workspace:* + version: link:../markdown-merge + + packages/ast-template: + dependencies: + '@structuredmerge/ast-merge': + specifier: workspace:* + version: link:../ast-merge + '@structuredmerge/markdown-merge': + specifier: workspace:* + version: link:../markdown-merge + '@structuredmerge/ruby-merge': + specifier: workspace:* + version: link:../ruby-merge + '@structuredmerge/toml-merge': + specifier: workspace:* + version: link:../toml-merge packages/go-merge: dependencies: @@ -74,6 +93,12 @@ importers: specifier: workspace:* version: link:../tree-haver + packages/kettle-nodule: + dependencies: + '@structuredmerge/ast-merge': + specifier: workspace:* + version: link:../ast-merge + packages/markdown-it-merge: dependencies: '@structuredmerge/markdown-merge': diff --git a/tsconfig.json b/tsconfig.json index 9f9d7a9..d9b9f15 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,9 +11,12 @@ "@structuredmerge/toml-merge": ["./packages/toml-merge/src/index.ts"], "@structuredmerge/peggy-toml-merge": ["./packages/peggy-toml-merge/src/index.ts"], "@structuredmerge/typescript-merge": ["./packages/typescript-merge/src/index.ts"], - "@structuredmerge/typescript-compiler-merge": ["./packages/typescript-compiler-merge/src/index.ts"], + "@structuredmerge/typescript-compiler-merge": [ + "./packages/typescript-compiler-merge/src/index.ts" + ], "@structuredmerge/yaml-merge": ["./packages/yaml-merge/src/index.ts"], - "@structuredmerge/js-yaml-merge": ["./packages/js-yaml-merge/src/index.ts"] + "@structuredmerge/js-yaml-merge": ["./packages/js-yaml-merge/src/index.ts"], + "@structuredmerge/kettle-nodule": ["./packages/kettle-nodule/src/index.ts"] }, "types": ["node", "vitest/globals"] },