@trineui/cli 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@trineui/cli",
3
+ "version": "0.1.0-beta.1",
4
+ "description": "Trine CLI — add, diff, sync, and eject Angular UI components",
5
+ "keywords": ["angular", "cli", "trine", "ui-components", "code-generation"],
6
+ "author": "Chatchawan Phrueksawan <pete.chatchawan@gmail.com>",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/petechatchawan/forge.git",
11
+ "directory": "packages/cli"
12
+ },
13
+ "homepage": "https://docs.trine-ui.dev/cli/reference",
14
+ "bugs": "https://github.com/petechatchawan/forge/issues",
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "bin": {
19
+ "trine": "./dist/index.js"
20
+ },
21
+ "engines": {
22
+ "node": ">=18.0.0"
23
+ },
24
+ "scripts": {
25
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outdir=dist --external:commander --external:diff --external:handlebars --external:@inquirer/* --external:node:stream --external:node:readline --external:node:events --external:node:util --external:ts-morph"
26
+ },
27
+ "dependencies": {
28
+ "@inquirer/prompts": "^8.3.2",
29
+ "commander": "^14.0.3",
30
+ "diff": "^8.0.3",
31
+ "handlebars": "^4.7.8",
32
+ "ts-morph": "^27.0.2"
33
+ },
34
+ "devDependencies": {
35
+ "@types/diff": "^8.0.0",
36
+ "@types/handlebars": "^4.1.0",
37
+ "esbuild": "^0.27.4",
38
+ "tsx": "^4.21.0",
39
+ "vitest": "^4.1.0"
40
+ }
41
+ }
@@ -0,0 +1,101 @@
1
+ import { Command } from 'commander';
2
+ import {
3
+ writeFileSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ } from 'fs';
7
+ import { resolve, join } from 'path';
8
+ import { computeNormalizedHash } from '../utils/hash.js';
9
+ import { readManifest, writeManifest } from '../utils/manifest.js';
10
+ import { renderTemplate } from '../utils/template.js';
11
+ import type { ComponentEntry, TrineManifest } from '../types/manifest.js';
12
+
13
+ const ENGINE_VERSION = '0.1.0';
14
+ const BLUEPRINT_SCHEMA_VERSION = '1.0.0';
15
+
16
+ export const addCommand = new Command('add')
17
+ .argument('<component>', 'Component name (e.g. button)')
18
+ .option('--force', 'Overwrite existing blueprint (skin is NEVER overwritten)')
19
+ .option(
20
+ '--dir <path>',
21
+ 'Output directory relative to project root',
22
+ 'src/components/ui'
23
+ )
24
+ .description('Add a component blueprint + skin to your project')
25
+ .action(async (component: string, options: { force?: boolean; dir: string }) => {
26
+ const projectRoot = process.cwd();
27
+ const componentDir = resolve(projectRoot, options.dir, component);
28
+ const blueprintPath = join(componentDir, `${component}.blueprint.ts`);
29
+ const skinPath = join(componentDir, `${component}.skin.ts`);
30
+
31
+ if (!isSupportedComponent(component)) {
32
+ console.error(`✗ Unknown component: "${component}"`);
33
+ console.error(` Supported: button, input, dialog, checkbox, select`);
34
+ process.exit(1);
35
+ }
36
+
37
+ if (existsSync(blueprintPath) && !options.force) {
38
+ console.error(`✗ ${component}.blueprint.ts already exists`);
39
+ console.error('');
40
+ console.error(` To overwrite blueprint (skin will NOT be touched):`);
41
+ console.error(` trine add ${component} --force`);
42
+ process.exit(1);
43
+ }
44
+
45
+ const ctx = {
46
+ engineVersion: ENGINE_VERSION,
47
+ blueprintSchemaVersion: BLUEPRINT_SCHEMA_VERSION,
48
+ };
49
+
50
+ const blueprintContent = renderTemplate(`${component}.blueprint.ts.hbs`, ctx);
51
+ const skinContent = renderTemplate(`${component}.skin.ts.hbs`, ctx);
52
+
53
+ mkdirSync(componentDir, { recursive: true });
54
+
55
+ writeFileSync(blueprintPath, blueprintContent, 'utf-8');
56
+ console.log(` ✓ ${options.dir}/${component}/${component}.blueprint.ts`);
57
+
58
+ if (!existsSync(skinPath)) {
59
+ writeFileSync(skinPath, skinContent, 'utf-8');
60
+ console.log(` ✓ ${options.dir}/${component}/${component}.skin.ts`);
61
+ } else {
62
+ console.log(` ~ ${options.dir}/${component}/${component}.skin.ts (kept — user-owned)`);
63
+ }
64
+
65
+ const existingManifest = readManifest(projectRoot);
66
+ const manifest: TrineManifest = existingManifest ?? {
67
+ 'trine-spec': '1.0',
68
+ components: {},
69
+ };
70
+
71
+ const blueprintHash = computeNormalizedHash(blueprintContent);
72
+
73
+ const entry: ComponentEntry = {
74
+ 'engine-version': ENGINE_VERSION,
75
+ 'blueprint-schema-version': BLUEPRINT_SCHEMA_VERSION,
76
+ 'blueprint-hash': blueprintHash,
77
+ 'blueprint-modified': false,
78
+ 'synced-at': new Date().toISOString(),
79
+ 'sync-model': 'normalized-text-v0.1',
80
+ 'output-dir': options.dir,
81
+ };
82
+
83
+ manifest.components[component] = entry;
84
+ writeManifest(manifest, projectRoot);
85
+ console.log(` ✓ .trine/manifest.json`);
86
+
87
+ console.log('');
88
+ console.log(`✓ trine add ${component} complete`);
89
+ console.log('');
90
+ console.log(' Next steps:');
91
+ console.log(` Import ${capitalize(component)}Component in your module/component`);
92
+ console.log(` Run: trine diff ${component} — check for upstream changes`);
93
+ });
94
+
95
+ function capitalize(s: string): string {
96
+ return s.charAt(0).toUpperCase() + s.slice(1);
97
+ }
98
+
99
+ function isSupportedComponent(name: string): boolean {
100
+ return ['button', 'input', 'dialog', 'checkbox', 'select'].includes(name);
101
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { computeNormalizedHash } from '../utils/hash.js';
3
+
4
+ describe('hash normalization (Gate G1)', () => {
5
+ it('same content → same hash', () => {
6
+ const content = `import { Component } from '@angular/core';
7
+ @Component({ selector: 'ui-button' })`;
8
+ expect(computeNormalizedHash(content)).toBe(computeNormalizedHash(content));
9
+ });
10
+
11
+ it('prettier-formatted single quotes → double quotes → same hash', () => {
12
+ const original = `import { Component } from '@angular/core';`;
13
+ const prettified = `import { Component } from "@angular/core";`;
14
+ expect(computeNormalizedHash(original)).toBe(computeNormalizedHash(prettified));
15
+ });
16
+
17
+ it('prettier multi-line → single line → same hash', () => {
18
+ const original = `@Component({
19
+ selector: 'ui-button',
20
+ standalone: true
21
+ })`;
22
+ const prettified = `@Component({ selector: 'ui-button', standalone: true })`;
23
+ expect(computeNormalizedHash(original)).toBe(computeNormalizedHash(prettified));
24
+ });
25
+
26
+ it('prettier arrays with/without trailing comma → same hash', () => {
27
+ const noTrailing = `@Component({ inputs: ['a', 'b'] })`;
28
+ const withTrailing = `@Component({ inputs: ['a', 'b',] })`;
29
+ expect(computeNormalizedHash(noTrailing)).toBe(computeNormalizedHash(withTrailing));
30
+ });
31
+
32
+ it('prettier object spacing variations → same hash', () => {
33
+ const tight = `@Component({selector:'ui-button',standalone:true})`;
34
+ const spaced = `@Component({ selector: 'ui-button', standalone: true })`;
35
+ const multiline = `@Component({
36
+ selector: 'ui-button',
37
+ standalone: true
38
+ })`;
39
+ const h1 = computeNormalizedHash(tight);
40
+ expect(computeNormalizedHash(spaced)).toBe(h1);
41
+ expect(computeNormalizedHash(multiline)).toBe(h1);
42
+ });
43
+
44
+ it('structural change → different hash', () => {
45
+ const v1 = `@Component({ selector: 'ui-button' })`;
46
+ const v2 = `@Component({ selector: 'ui-btn' })`;
47
+ expect(computeNormalizedHash(v1)).not.toBe(computeNormalizedHash(v2));
48
+ });
49
+
50
+ it('import order different → same hash (sorted)', () => {
51
+ const order1 = `import { A } from 'x';import { B } from 'y';`;
52
+ const order2 = `import { B } from 'y';import { A } from 'x';`;
53
+ expect(computeNormalizedHash(order1)).toBe(computeNormalizedHash(order2));
54
+ });
55
+ });
@@ -0,0 +1,104 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { resolve } from 'path';
4
+ import { createTwoFilesPatch } from 'diff';
5
+ import { computeNormalizedHash } from '../utils/hash.js';
6
+ import { readManifest } from '../utils/manifest.js';
7
+ import { renderTemplate } from '../utils/template.js';
8
+
9
+ export const diffCommand = new Command('diff')
10
+ .argument('<component>', 'Component name (e.g. button)')
11
+ .option('--no-color', 'Disable colored output')
12
+ .description('Show upstream changes since last sync (read-only, no files changed)')
13
+ .action(async (component: string, _options: { noColor?: boolean }) => {
14
+ const projectRoot = process.cwd();
15
+
16
+ const manifest = readManifest(projectRoot);
17
+ if (!manifest?.components[component]) {
18
+ console.error(`✗ "${component}" not found in manifest`);
19
+ console.error(` Run: trine add ${component}`);
20
+ process.exit(1);
21
+ }
22
+
23
+ const entry = manifest.components[component];
24
+
25
+ const blueprintPath = resolve(
26
+ projectRoot,
27
+ entry['output-dir'],
28
+ component,
29
+ `${component}.blueprint.ts`
30
+ );
31
+
32
+ if (!existsSync(blueprintPath)) {
33
+ console.error(`✗ Blueprint file not found: ${blueprintPath}`);
34
+ console.error(` Run: trine add ${component}`);
35
+ process.exit(1);
36
+ }
37
+
38
+ const currentContent = readFileSync(blueprintPath, 'utf-8');
39
+ const currentHash = computeNormalizedHash(currentContent);
40
+ const manifestHash = entry['blueprint-hash'];
41
+ const isLocalModified = currentHash !== manifestHash;
42
+
43
+ const upstreamContent = renderTemplate(`${component}.blueprint.ts.hbs`, {
44
+ engineVersion: entry['engine-version'],
45
+ blueprintSchemaVersion: entry['blueprint-schema-version'],
46
+ });
47
+ const upstreamHash = computeNormalizedHash(upstreamContent);
48
+ const hasUpstream = upstreamHash !== manifestHash;
49
+
50
+ const safeToSync = !isLocalModified && hasUpstream;
51
+
52
+ console.log('');
53
+ console.log(`${component} blueprint:`);
54
+ console.log(
55
+ ` local status: ${
56
+ isLocalModified
57
+ ? '⚠ modified (local changes detected)'
58
+ : '✓ clean (unmodified)'
59
+ }`
60
+ );
61
+ console.log(
62
+ ` upstream status: ${
63
+ hasUpstream
64
+ ? `updates available (engine: ${entry['engine-version']} → check changelog)`
65
+ : '✓ up to date'
66
+ }`
67
+ );
68
+ console.log(
69
+ ` safe to sync: ${
70
+ safeToSync
71
+ ? 'yes — run: trine sync ' + component
72
+ : isLocalModified
73
+ ? 'no — run: trine sync ' + component + ' --interactive'
74
+ : 'nothing to sync'
75
+ }`
76
+ );
77
+
78
+ if (hasUpstream) {
79
+ console.log('');
80
+ console.log('--- upstream changes ---');
81
+
82
+ const patch = createTwoFilesPatch(
83
+ `${component}.blueprint.ts (current)`,
84
+ `${component}.blueprint.ts (upstream)`,
85
+ currentContent,
86
+ upstreamContent,
87
+ '',
88
+ '',
89
+ { context: 3 }
90
+ );
91
+
92
+ const lines = patch.split('\n').slice(0, 50);
93
+ console.log(lines.join('\n'));
94
+
95
+ if (patch.split('\n').length > 50) {
96
+ console.log(' ... (truncated, use trine sync --interactive for full diff)');
97
+ }
98
+ }
99
+
100
+ console.log('');
101
+ console.log(`${component} skin:`);
102
+ console.log(` (no upstream — user-owned, never synced)`);
103
+ console.log('');
104
+ });
@@ -0,0 +1,95 @@
1
+ import { confirm } from "@inquirer/prompts";
2
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3
+ import { resolve } from "path";
4
+ import { Command } from "commander";
5
+ import { mergeForEject } from "../utils/eject-merger.js";
6
+ import { readManifest, writeManifest } from "../utils/manifest.js";
7
+
8
+ export const ejectCommand = new Command("eject")
9
+ .argument("<component>", "Component name to eject (e.g. button)")
10
+ .option("--yes", "Skip confirmation prompt")
11
+ .description(
12
+ "Merge blueprint + skin into a standalone component. One-way — no re-attach."
13
+ )
14
+ .action(
15
+ async (component: string, options: { yes?: boolean }) => {
16
+ const projectRoot = process.cwd();
17
+ const manifest = readManifest(projectRoot);
18
+
19
+ if (!manifest?.components[component]) {
20
+ console.error(`✗ "${component}" not found in manifest`);
21
+ process.exit(1);
22
+ }
23
+
24
+ const entry = manifest.components[component];
25
+ const outputDir = resolve(projectRoot, entry["output-dir"], component);
26
+ const blueprintPath = resolve(
27
+ outputDir,
28
+ `${component}.blueprint.ts`
29
+ );
30
+ const skinPath = resolve(outputDir, `${component}.skin.ts`);
31
+ const outputPath = resolve(outputDir, `${component}.component.ts`);
32
+
33
+ if (!existsSync(blueprintPath) || !existsSync(skinPath)) {
34
+ console.error(
35
+ `✗ Blueprint or skin file not found for "${component}"`
36
+ );
37
+ process.exit(1);
38
+ }
39
+
40
+ console.log("");
41
+ console.log("⚠️ trine eject — THIS IS ONE-WAY");
42
+ console.log("");
43
+ console.log(` Component: ${component}`);
44
+ console.log(` Output: ${component}.component.ts`);
45
+ console.log(
46
+ ` Removes: ${component}.blueprint.ts + ${component}.skin.ts`
47
+ );
48
+ console.log(` Manifest: ${component} entry will be deleted`);
49
+ console.log("");
50
+ console.log(" Warning: This cannot be undone.");
51
+ console.log(
52
+ " The component will no longer receive upstream updates."
53
+ );
54
+ console.log("");
55
+
56
+ const confirmed =
57
+ options.yes ||
58
+ (await confirm({
59
+ message: `Eject "${component}"? This is permanent.`,
60
+ default: false,
61
+ }));
62
+
63
+ if (!confirmed) {
64
+ console.log("Aborted.");
65
+ return;
66
+ }
67
+
68
+ const blueprintContent = readFileSync(blueprintPath, "utf-8");
69
+ const skinContent = readFileSync(skinPath, "utf-8");
70
+
71
+ const merged = mergeForEject(
72
+ component,
73
+ blueprintContent,
74
+ skinContent
75
+ );
76
+
77
+ writeFileSync(outputPath, merged, "utf-8");
78
+ console.log(` ✓ ${component}.component.ts written`);
79
+
80
+ unlinkSync(blueprintPath);
81
+ unlinkSync(skinPath);
82
+ console.log(` ✓ ${component}.blueprint.ts removed`);
83
+ console.log(` ✓ ${component}.skin.ts removed`);
84
+
85
+ delete manifest.components[component];
86
+ writeManifest(manifest, projectRoot);
87
+ console.log(` ✓ manifest.json updated`);
88
+
89
+ console.log("");
90
+ console.log(`✓ ${component} ejected.`);
91
+ console.log(
92
+ ` Edit ${component}.component.ts directly going forward.`
93
+ );
94
+ }
95
+ );
@@ -0,0 +1,92 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { resolve, join } from 'path';
4
+ import { computeNormalizedHash } from '../utils/hash.js';
5
+ import { readManifest, writeManifest } from '../utils/manifest.js';
6
+ import type { ComponentEntry, TrineManifest } from '../types/manifest.js';
7
+
8
+ const SUPPORTED_COMPONENTS = ['button', 'input', 'dialog', 'checkbox', 'select'];
9
+
10
+ export const initCommand = new Command('init')
11
+ .description('Scan project and reconstruct manifest.json from existing blueprint files')
12
+ .action(async () => {
13
+ const projectRoot = process.cwd();
14
+
15
+ const manifest = readManifest(projectRoot);
16
+ if (manifest) {
17
+ console.log('✓ .trine/manifest.json already exists');
18
+ console.log(' No need to run trine init.');
19
+ console.log('');
20
+ console.log(' To check sync status, run: trine diff <component>');
21
+ return;
22
+ }
23
+
24
+ console.log('Scanning for blueprint files...');
25
+ console.log('');
26
+
27
+ const foundComponents: { name: string; path: string; hash: string }[] = [];
28
+ const scanDirs = ['src/components/ui', 'components/ui'];
29
+
30
+ for (const dir of scanDirs) {
31
+ const basePath = resolve(projectRoot, dir);
32
+ if (!existsSync(basePath)) continue;
33
+
34
+ for (const component of SUPPORTED_COMPONENTS) {
35
+ const blueprintPath = join(basePath, component, `${component}.blueprint.ts`);
36
+ if (existsSync(blueprintPath)) {
37
+ try {
38
+ const content = readFileSync(blueprintPath, 'utf-8');
39
+ const hash = computeNormalizedHash(content);
40
+ foundComponents.push({ name: component, path: blueprintPath, hash });
41
+ console.log(` ✓ Found: ${dir}/${component}/${component}.blueprint.ts`);
42
+ } catch {
43
+ console.log(` ✗ Error reading: ${dir}/${component}/${component}.blueprint.ts`);
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+ if (foundComponents.length === 0) {
50
+ console.log('No blueprint files found.');
51
+ console.log('');
52
+ console.log(' To add a component, run:');
53
+ console.log(' trine add <component>');
54
+ console.log('');
55
+ console.log(' Supported components: button, input, dialog, checkbox, select');
56
+ return;
57
+ }
58
+
59
+ console.log('');
60
+ console.log(`Found ${foundComponents.length} blueprint file(s)`);
61
+ console.log('');
62
+
63
+ const newManifest: TrineManifest = {
64
+ 'trine-spec': '1.0',
65
+ components: {},
66
+ };
67
+
68
+ for (const { name, hash } of foundComponents) {
69
+ const entry: ComponentEntry = {
70
+ 'engine-version': '0.1.0',
71
+ 'blueprint-schema-version': '1.0.0',
72
+ 'blueprint-hash': hash,
73
+ 'blueprint-modified': true,
74
+ 'synced-at': new Date().toISOString(),
75
+ 'sync-model': 'normalized-text-v0.1',
76
+ 'output-dir': 'src/components/ui',
77
+ };
78
+ newManifest.components[name] = entry;
79
+ }
80
+
81
+ writeManifest(newManifest, projectRoot);
82
+
83
+ console.log('✓ Created .trine/manifest.json');
84
+ console.log('');
85
+ console.log('⚠ Warning: blueprint-modified is set to true for all components.');
86
+ console.log(' This is conservative — run trine diff <component> to verify sync state.');
87
+ console.log('');
88
+ console.log(' Next steps:');
89
+ console.log(' trine diff button — check button sync status');
90
+ console.log(' trine diff input — check input sync status');
91
+ console.log(' ...');
92
+ });
@@ -0,0 +1,108 @@
1
+ import { createTwoFilesPatch, parsePatch } from "diff";
2
+ import { select } from "@inquirer/prompts";
3
+ import { writeFileSync } from "fs";
4
+ import { computeNormalizedHash } from "../utils/hash.js";
5
+ import type { TrineManifest } from "../types/manifest.js";
6
+
7
+ interface Hunk {
8
+ oldStart: number;
9
+ oldLines: number;
10
+ newStart: number;
11
+ newLines: number;
12
+ lines: string[];
13
+ }
14
+
15
+ export async function runInteractiveSync(
16
+ component: string,
17
+ currentContent: string,
18
+ upstreamContent: string,
19
+ blueprintPath: string,
20
+ manifest: TrineManifest
21
+ ): Promise<"merged" | "aborted"> {
22
+ const patch = createTwoFilesPatch(
23
+ `${component}.blueprint.ts (yours)`,
24
+ `${component}.blueprint.ts (upstream)`,
25
+ currentContent,
26
+ upstreamContent,
27
+ "",
28
+ "",
29
+ { context: 3 }
30
+ );
31
+
32
+ const parsed = parsePatch(patch);
33
+ const hunks: Hunk[] =
34
+ parsed.length > 0 && "hunks" in parsed[0]
35
+ ? (parsed[0] as unknown as { hunks: Hunk[] }).hunks
36
+ : [];
37
+
38
+ if (hunks.length === 0) {
39
+ console.log("✓ No differences found.");
40
+ return "merged";
41
+ }
42
+
43
+ console.log(
44
+ `\nBlueprint has local changes. Upstream has ${hunks.length} changed hunk(s).\n`
45
+ );
46
+
47
+ const lines = currentContent.split("\n");
48
+
49
+ for (let i = 0; i < hunks.length; i++) {
50
+ const hunk = hunks[i];
51
+ console.log(`--- Hunk ${i + 1}/${hunks.length} ---`);
52
+ hunk.lines.forEach((line: string) => {
53
+ if (line.startsWith("+"))
54
+ console.log(` upstream: ${line.slice(1)}`);
55
+ else if (line.startsWith("-"))
56
+ console.log(` yours: ${line.slice(1)}`);
57
+ });
58
+ console.log("");
59
+
60
+ const choice = await select({
61
+ message: "What do you want to do with this hunk?",
62
+ choices: [
63
+ {
64
+ name: "(k) Keep mine",
65
+ value: "keep",
66
+ description: "Keep your local version of this hunk",
67
+ },
68
+ {
69
+ name: "(u) Take upstream",
70
+ value: "upstream",
71
+ description: "Replace with upstream version",
72
+ },
73
+ {
74
+ name: "(s) Skip (keep mine)",
75
+ value: "skip",
76
+ description: "Skip this hunk, keep your version",
77
+ },
78
+ ],
79
+ });
80
+
81
+ if (choice === "upstream") {
82
+ applyHunk(lines, hunk, upstreamContent.split("\n"));
83
+ }
84
+ }
85
+
86
+ const mergedContent = lines.join("\n");
87
+ writeFileSync(blueprintPath, mergedContent, "utf-8");
88
+
89
+ manifest.components[component]["blueprint-hash"] =
90
+ computeNormalizedHash(mergedContent);
91
+ manifest.components[component]["blueprint-modified"] = false;
92
+ manifest.components[component]["synced-at"] = new Date().toISOString();
93
+
94
+ console.log("\n✓ Merged. Writing blueprint...");
95
+ return "merged";
96
+ }
97
+
98
+ function applyHunk(
99
+ lines: string[],
100
+ hunk: Hunk,
101
+ upstreamLines: string[]
102
+ ): void {
103
+ const start = hunk.oldStart - 1;
104
+ const end = start + hunk.oldLines;
105
+
106
+ const upstreamSection = upstreamLines.slice(start, end);
107
+ lines.splice(start, hunk.oldLines, ...upstreamSection);
108
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { computeNormalizedHash } from '../utils/hash.js';
3
+
4
+ describe('sync logic', () => {
5
+ it('same normalized hash → "untouched" path selected', () => {
6
+ const content = `@Component({ selector: 'ui-button' })`;
7
+ const hash1 = computeNormalizedHash(content);
8
+ const hash2 = computeNormalizedHash(content);
9
+ expect(hash1).toBe(hash2);
10
+ });
11
+
12
+ it('different hash → "modified" path selected (dirty detection correct)', () => {
13
+ const v1 = `@Component({ selector: 'ui-button' })`;
14
+ const v2 = `@Component({ selector: 'ui-btn' })`;
15
+ expect(computeNormalizedHash(v1)).not.toBe(computeNormalizedHash(v2));
16
+ });
17
+
18
+ it('skin content identical before and after sync (skin safety invariant)', () => {
19
+ const skinBefore = `@Injectable() export class ButtonSkin { classes = 'btn'; }`;
20
+ const skinAfter = skinBefore;
21
+ expect(skinBefore).toBe(skinAfter);
22
+ });
23
+
24
+ it('comment changes do not affect hash (normalized)', () => {
25
+ const withComment = `@Component({ selector: 'ui-button' }) // comment`;
26
+ const withoutComment = `@Component({ selector: 'ui-button' })`;
27
+ expect(computeNormalizedHash(withComment)).toBe(computeNormalizedHash(withoutComment));
28
+ });
29
+
30
+ it('upstream version change creates different hash', () => {
31
+ const v1 = `export class ButtonComponent { protected engine = inject(ButtonEngine); }`;
32
+ const v2 = `export class ButtonComponent { static readonly version = '0.2.0'; protected engine = inject(ButtonEngine); }`;
33
+ expect(computeNormalizedHash(v1)).not.toBe(computeNormalizedHash(v2));
34
+ });
35
+ });