@shazhou/proman-core 0.9.0

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.
Files changed (129) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +18 -0
  3. package/dist/commands/bump.d.ts +13 -0
  4. package/dist/commands/bump.d.ts.map +1 -0
  5. package/dist/commands/bump.js +115 -0
  6. package/dist/commands/deploy.d.ts +9 -0
  7. package/dist/commands/deploy.d.ts.map +1 -0
  8. package/dist/commands/deploy.js +42 -0
  9. package/dist/commands/dev.d.ts +15 -0
  10. package/dist/commands/dev.d.ts.map +1 -0
  11. package/dist/commands/dev.js +175 -0
  12. package/dist/commands/index.d.ts +7 -0
  13. package/dist/commands/index.d.ts.map +1 -0
  14. package/dist/commands/index.js +7 -0
  15. package/dist/commands/init.d.ts +5 -0
  16. package/dist/commands/init.d.ts.map +1 -0
  17. package/dist/commands/init.js +262 -0
  18. package/dist/commands/link.d.ts +19 -0
  19. package/dist/commands/link.d.ts.map +1 -0
  20. package/dist/commands/link.js +155 -0
  21. package/dist/commands/publish.d.ts +18 -0
  22. package/dist/commands/publish.d.ts.map +1 -0
  23. package/dist/commands/publish.js +125 -0
  24. package/dist/config/index.d.ts +4 -0
  25. package/dist/config/index.d.ts.map +1 -0
  26. package/dist/config/index.js +2 -0
  27. package/dist/config/load-config.d.ts +6 -0
  28. package/dist/config/load-config.d.ts.map +1 -0
  29. package/dist/config/load-config.js +29 -0
  30. package/dist/config/types.d.ts +17 -0
  31. package/dist/config/types.d.ts.map +1 -0
  32. package/dist/config/types.js +1 -0
  33. package/dist/config/validate-config.d.ts +7 -0
  34. package/dist/config/validate-config.d.ts.map +1 -0
  35. package/dist/config/validate-config.js +72 -0
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +6 -0
  39. package/dist/utils/changeset.d.ts +16 -0
  40. package/dist/utils/changeset.d.ts.map +1 -0
  41. package/dist/utils/changeset.js +80 -0
  42. package/dist/utils/fingerprint.d.ts +38 -0
  43. package/dist/utils/fingerprint.d.ts.map +1 -0
  44. package/dist/utils/fingerprint.js +182 -0
  45. package/dist/utils/git.d.ts +23 -0
  46. package/dist/utils/git.d.ts.map +1 -0
  47. package/dist/utils/git.js +105 -0
  48. package/dist/utils/index.d.ts +8 -0
  49. package/dist/utils/index.d.ts.map +1 -0
  50. package/dist/utils/index.js +8 -0
  51. package/dist/utils/npm.d.ts +30 -0
  52. package/dist/utils/npm.d.ts.map +1 -0
  53. package/dist/utils/npm.js +85 -0
  54. package/dist/utils/smoke-test.d.ts +7 -0
  55. package/dist/utils/smoke-test.d.ts.map +1 -0
  56. package/dist/utils/smoke-test.js +59 -0
  57. package/dist/utils/version.d.ts +5 -0
  58. package/dist/utils/version.d.ts.map +1 -0
  59. package/dist/utils/version.js +36 -0
  60. package/dist/utils/workspace.d.ts +21 -0
  61. package/dist/utils/workspace.d.ts.map +1 -0
  62. package/dist/utils/workspace.js +73 -0
  63. package/package.json +45 -0
  64. package/src/commands/bump.ts +131 -0
  65. package/src/commands/deploy.ts +52 -0
  66. package/src/commands/dev.ts +214 -0
  67. package/src/commands/index.ts +7 -0
  68. package/src/commands/init.integration.test.ts +59 -0
  69. package/src/commands/init.test.ts +179 -0
  70. package/src/commands/init.ts +290 -0
  71. package/src/commands/link.ts +195 -0
  72. package/src/commands/publish.ts +168 -0
  73. package/src/config/index.ts +8 -0
  74. package/src/config/load-config.ts +33 -0
  75. package/src/config/types.ts +19 -0
  76. package/src/config/validate-config.ts +81 -0
  77. package/src/index.ts +29 -0
  78. package/src/utils/changeset.ts +98 -0
  79. package/src/utils/fingerprint.ts +199 -0
  80. package/src/utils/git.ts +119 -0
  81. package/src/utils/index.ts +8 -0
  82. package/src/utils/npm.ts +110 -0
  83. package/src/utils/smoke-test.ts +79 -0
  84. package/src/utils/version.ts +41 -0
  85. package/src/utils/workspace.ts +94 -0
  86. package/tests/build-fingerprint-integration.test.ts +403 -0
  87. package/tests/bump.test.ts +261 -0
  88. package/tests/changeset.test.ts +147 -0
  89. package/tests/deploy.test.ts +98 -0
  90. package/tests/dev.test.ts +756 -0
  91. package/tests/fingerprint.test.ts +316 -0
  92. package/tests/fixtures/api-only/packages/api/.gitkeep +0 -0
  93. package/tests/fixtures/api-only/proman.yaml +4 -0
  94. package/tests/fixtures/bad-packages/proman.yaml +1 -0
  95. package/tests/fixtures/bun-project/packages/a/.gitkeep +0 -0
  96. package/tests/fixtures/bun-project/proman.yaml +4 -0
  97. package/tests/fixtures/defaults/proman.yaml +3 -0
  98. package/tests/fixtures/no-deployable/packages/core/.gitkeep +0 -0
  99. package/tests/fixtures/no-deployable/packages/mycli/.gitkeep +0 -0
  100. package/tests/fixtures/no-deployable/proman.yaml +7 -0
  101. package/tests/fixtures/node-runtime/packages/a/package.json +5 -0
  102. package/tests/fixtures/node-runtime/proman.yaml +3 -0
  103. package/tests/fixtures/pnpm-project/packages/a/package.json +1 -0
  104. package/tests/fixtures/pnpm-project/pnpm-lock.yaml +0 -0
  105. package/tests/fixtures/pnpm-project/proman.yaml +3 -0
  106. package/tests/fixtures/typed/packages/api/.gitkeep +0 -0
  107. package/tests/fixtures/typed/packages/core/.gitkeep +0 -0
  108. package/tests/fixtures/typed/packages/dashboard/.gitkeep +0 -0
  109. package/tests/fixtures/typed/packages/mycli/.gitkeep +0 -0
  110. package/tests/fixtures/typed/proman.yaml +13 -0
  111. package/tests/fixtures/valid/packages/cli/package.json +5 -0
  112. package/tests/fixtures/valid/packages/core/package.json +5 -0
  113. package/tests/fixtures/valid/packages/fs/package.json +5 -0
  114. package/tests/fixtures/valid/proman.yaml +13 -0
  115. package/tests/fixtures/webui-only/packages/dashboard/.gitkeep +0 -0
  116. package/tests/fixtures/webui-only/proman.yaml +4 -0
  117. package/tests/link.test.ts +419 -0
  118. package/tests/load-config.test.ts +44 -0
  119. package/tests/npm.test.ts +199 -0
  120. package/tests/publish.test.ts +599 -0
  121. package/tests/smoke-test.test.ts +211 -0
  122. package/tests/validate-config.test.ts +67 -0
  123. package/tests/version.test.ts +86 -0
  124. package/tests/workflow-schema.test.ts +72 -0
  125. package/tests/workspace.test.ts +160 -0
  126. package/tsconfig.build.json +14 -0
  127. package/tsconfig.json +8 -0
  128. package/tsconfig.tsbuildinfo +1 -0
  129. package/vitest.config.ts +8 -0
@@ -0,0 +1,79 @@
1
+ import { readFile, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import type { SpawnFn } from './npm.js'
5
+
6
+ type PackageJson = {
7
+ name: string
8
+ version: string
9
+ bin?: string | Record<string, string>
10
+ }
11
+
12
+ /**
13
+ * Smoke test a package tarball by extracting it and running bin commands.
14
+ * Validates that the packaged artifact actually works before publishing.
15
+ */
16
+ export async function smokeTestTarball(pkgDir: string, spawn: SpawnFn): Promise<void> {
17
+ // Read package.json to check for bin entries
18
+ const pkgJsonPath = join(pkgDir, 'package.json')
19
+ const pkgJsonText = await readFile(pkgJsonPath, 'utf8')
20
+ const pkgJson = JSON.parse(pkgJsonText) as PackageJson
21
+
22
+ // Skip if no bin entry
23
+ if (!pkgJson.bin) {
24
+ return
25
+ }
26
+
27
+ // Normalize bin to Record format
28
+ const binEntries: Record<string, string> =
29
+ typeof pkgJson.bin === 'string' ? { [pkgJson.name]: pkgJson.bin } : pkgJson.bin
30
+
31
+ // Skip if bin is empty
32
+ if (Object.keys(binEntries).length === 0) {
33
+ return
34
+ }
35
+
36
+ // Step 1: Create tarball with pnpm pack
37
+ const packResult = await spawn(['pnpm', 'pack'], pkgDir)
38
+ if (packResult.code !== 0) {
39
+ throw new Error(`pnpm pack failed: ${packResult.stderr || packResult.stdout}`)
40
+ }
41
+
42
+ const tarballName = packResult.stdout.trim()
43
+ if (!tarballName) {
44
+ throw new Error('pnpm pack did not return tarball filename')
45
+ }
46
+
47
+ // Step 2: Extract tarball to temp directory
48
+ const { mkdtemp } = await import('node:fs/promises')
49
+ const testDir = await mkdtemp(join(tmpdir(), 'proman-smoke-'))
50
+
51
+ try {
52
+ // Extract tarball
53
+ const tarballPath = join(pkgDir, tarballName)
54
+ const extractResult = await spawn(['tar', '-xzf', tarballPath, '-C', testDir], pkgDir)
55
+ if (extractResult.code !== 0) {
56
+ throw new Error(`tar extract failed: ${extractResult.stderr}`)
57
+ }
58
+
59
+ // pnpm pack creates a 'package/' directory inside the tarball
60
+ const extractedPkgDir = join(testDir, 'package')
61
+
62
+ // Step 3: Test each bin entry
63
+ for (const [binName, binPath] of Object.entries(binEntries)) {
64
+ const binFullPath = join(extractedPkgDir, binPath)
65
+ const binTestResult = await spawn(['node', binFullPath, '--version'], extractedPkgDir)
66
+
67
+ if (binTestResult.code !== 0) {
68
+ const errorMsg = binTestResult.stderr.trim() || binTestResult.stdout.trim()
69
+ throw new Error(
70
+ `smoke test failed for bin '${binName}': ${errorMsg || 'non-zero exit code'}`,
71
+ )
72
+ }
73
+ }
74
+ } finally {
75
+ // Step 4: Always clean up temp directory and tarball
76
+ await rm(testDir, { recursive: true, force: true })
77
+ await rm(join(pkgDir, tarballName), { force: true })
78
+ }
79
+ }
@@ -0,0 +1,41 @@
1
+ import type { Bump, Changeset } from './changeset.js'
2
+
3
+ const VERSION_CORE_RE = /^(\d+)\.(\d+)\.(\d+)(?:-[\w.+-]+)?$/
4
+
5
+ export function bumpVersion(current: string, bump: Bump): string {
6
+ const m = current.match(VERSION_CORE_RE)
7
+ if (!m) {
8
+ throw new Error(`invalid version: '${current}'`)
9
+ }
10
+ const major = Number(m[1])
11
+ const minor = Number(m[2])
12
+ const patch = Number(m[3])
13
+ if (bump === 'major') return `${major + 1}.0.0`
14
+ if (bump === 'minor') return `${major}.${minor + 1}.0`
15
+ return `${major}.${minor}.${patch + 1}`
16
+ }
17
+
18
+ const ORDER: Record<Bump, number> = { patch: 1, minor: 2, major: 3 }
19
+
20
+ export function inferBump(changesets: Changeset[]): Record<string, Bump> {
21
+ const result: Record<string, Bump> = {}
22
+ for (const c of changesets) {
23
+ for (const [pkg, bump] of Object.entries(c.packages)) {
24
+ const current = result[pkg]
25
+ if (current === undefined || ORDER[bump] > ORDER[current]) {
26
+ result[pkg] = bump
27
+ }
28
+ }
29
+ }
30
+ return result
31
+ }
32
+
33
+ const TAG_RE = /^v?(\d+\.\d+\.\d+(?:-[\w.+-]+)?)$/
34
+
35
+ export function parseTagVersion(tag: string): string {
36
+ const m = tag.match(TAG_RE)
37
+ if (!m) {
38
+ throw new Error(`invalid tag: '${tag}'`)
39
+ }
40
+ return m[1] as string
41
+ }
@@ -0,0 +1,94 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+
4
+ export type PkgManifest = {
5
+ name: string
6
+ version: string
7
+ dependencies?: Record<string, string>
8
+ devDependencies?: Record<string, string>
9
+ [k: string]: unknown
10
+ }
11
+
12
+ type Unresolved = { pkg: string; dep: string }
13
+
14
+ function rewriteDepsField(
15
+ pkgName: string,
16
+ field: Record<string, string> | undefined,
17
+ versions: Map<string, string>,
18
+ unresolved: Unresolved[],
19
+ ): Record<string, string> | undefined {
20
+ if (!field) return undefined
21
+ const out: Record<string, string> = {}
22
+ let changed = false
23
+ for (const [dep, val] of Object.entries(field)) {
24
+ if (val === 'workspace:*') {
25
+ const v = versions.get(dep)
26
+ if (v) {
27
+ out[dep] = v
28
+ changed = true
29
+ } else {
30
+ out[dep] = val
31
+ unresolved.push({ pkg: pkgName, dep })
32
+ }
33
+ } else {
34
+ out[dep] = val
35
+ }
36
+ }
37
+ // Mark via reference identity if changed; caller can compare to original
38
+ void changed
39
+ return out
40
+ }
41
+
42
+ export function rewriteWorkspaceDeps(manifests: PkgManifest[]): {
43
+ rewritten: PkgManifest[]
44
+ unresolved: Unresolved[]
45
+ } {
46
+ const versions = new Map<string, string>()
47
+ for (const m of manifests) {
48
+ versions.set(m.name, m.version)
49
+ }
50
+ const unresolved: Unresolved[] = []
51
+ const rewritten = manifests.map((m) => {
52
+ const copy: PkgManifest = { ...m }
53
+ if (m.dependencies) {
54
+ copy.dependencies = rewriteDepsField(m.name, m.dependencies, versions, unresolved)
55
+ }
56
+ if (m.devDependencies) {
57
+ copy.devDependencies = rewriteDepsField(m.name, m.devDependencies, versions, unresolved)
58
+ }
59
+ return copy
60
+ })
61
+ return { rewritten, unresolved }
62
+ }
63
+
64
+ export async function applyWorkspaceRewrites(
65
+ rootDir: string,
66
+ packages: { name: string; path: string }[],
67
+ ): Promise<string[]> {
68
+ const paths = packages.map((p) => resolve(rootDir, p.path, 'package.json'))
69
+ const manifests: PkgManifest[] = []
70
+ for (let i = 0; i < paths.length; i++) {
71
+ const p = paths[i] as string
72
+ let text: string
73
+ try {
74
+ text = await readFile(p, 'utf8')
75
+ } catch (err) {
76
+ throw new Error(`package.json not found at ${p}: ${(err as Error).message}`)
77
+ }
78
+ manifests.push(JSON.parse(text) as PkgManifest)
79
+ }
80
+
81
+ const { rewritten } = rewriteWorkspaceDeps(manifests)
82
+ const changed: string[] = []
83
+ for (let i = 0; i < paths.length; i++) {
84
+ const before = JSON.stringify(manifests[i])
85
+ const after = JSON.stringify(rewritten[i])
86
+ if (before !== after) {
87
+ const p = paths[i] as string
88
+ const newText = `${JSON.stringify(rewritten[i], null, 2)}\n`
89
+ await writeFile(p, newText)
90
+ changed.push(p)
91
+ }
92
+ }
93
+ return changed
94
+ }
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Integration tests for build fingerprint caching (fixes #135).
3
+ *
4
+ * Verifies that build fingerprints stored inside `dist/` are correctly
5
+ * invalidated when the output directory is removed, and that incremental
6
+ * builds skip unchanged packages.
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { join, resolve } from 'node:path'
11
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
12
+ import { build } from '../src/commands/dev.ts'
13
+
14
+ let tmpDir: string
15
+
16
+ beforeEach(() => {
17
+ tmpDir = resolve(
18
+ tmpdir(),
19
+ `proman-build-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
20
+ )
21
+ mkdirSync(tmpDir, { recursive: true })
22
+ })
23
+
24
+ afterEach(() => {
25
+ rmSync(tmpDir, { recursive: true, force: true })
26
+ })
27
+
28
+ function setupMonorepo(): void {
29
+ // Create proman.yaml config
30
+ writeFileSync(
31
+ join(tmpDir, 'proman.yaml'),
32
+ `
33
+ packages:
34
+ - name: '@test/pkg'
35
+ path: packages/pkg
36
+ type: lib
37
+ `,
38
+ )
39
+
40
+ // Create package
41
+ mkdirSync(join(tmpDir, 'packages/pkg/src'), { recursive: true })
42
+ writeFileSync(join(tmpDir, 'packages/pkg/src/index.ts'), 'export const x = 1')
43
+ writeFileSync(
44
+ join(tmpDir, 'packages/pkg/package.json'),
45
+ JSON.stringify({ name: '@test/pkg', version: '1.0.0' }),
46
+ )
47
+ writeFileSync(join(tmpDir, 'packages/pkg/tsconfig.json'), '{}')
48
+ }
49
+
50
+ function setupMultiPackageMonorepo(): void {
51
+ writeFileSync(
52
+ join(tmpDir, 'proman.yaml'),
53
+ `
54
+ packages:
55
+ - name: '@test/core'
56
+ path: packages/core
57
+ type: lib
58
+ - name: '@test/fs'
59
+ path: packages/fs
60
+ type: lib
61
+ - name: '@test/cli'
62
+ path: packages/cli
63
+ type: cli
64
+ `,
65
+ )
66
+
67
+ // core — no workspace deps
68
+ mkdirSync(join(tmpDir, 'packages/core/src'), { recursive: true })
69
+ writeFileSync(join(tmpDir, 'packages/core/src/index.ts'), 'export const x = 1')
70
+ writeFileSync(
71
+ join(tmpDir, 'packages/core/package.json'),
72
+ JSON.stringify({ name: '@test/core', version: '1.0.0' }),
73
+ )
74
+ writeFileSync(join(tmpDir, 'packages/core/tsconfig.json'), '{}')
75
+
76
+ // fs — depends on core
77
+ mkdirSync(join(tmpDir, 'packages/fs/src'), { recursive: true })
78
+ writeFileSync(join(tmpDir, 'packages/fs/src/index.ts'), 'export const y = 2')
79
+ writeFileSync(
80
+ join(tmpDir, 'packages/fs/package.json'),
81
+ JSON.stringify({
82
+ name: '@test/fs',
83
+ version: '1.0.0',
84
+ dependencies: { '@test/core': 'workspace:*' },
85
+ }),
86
+ )
87
+ writeFileSync(join(tmpDir, 'packages/fs/tsconfig.json'), '{}')
88
+
89
+ // cli — depends on fs
90
+ mkdirSync(join(tmpDir, 'packages/cli/src'), { recursive: true })
91
+ writeFileSync(join(tmpDir, 'packages/cli/src/index.ts'), 'export const z = 3')
92
+ writeFileSync(
93
+ join(tmpDir, 'packages/cli/package.json'),
94
+ JSON.stringify({
95
+ name: '@test/cli',
96
+ version: '1.0.0',
97
+ dependencies: { '@test/fs': 'workspace:*' },
98
+ }),
99
+ )
100
+ writeFileSync(join(tmpDir, 'packages/cli/tsconfig.json'), '{}')
101
+ }
102
+
103
+ // Mock spawn function that simulates successful tsc builds
104
+ const mockSpawn = async (_argv: string[], cwd: string) => {
105
+ // Simulate tsc build by creating dist folder with output
106
+ const distDir = join(cwd, 'dist')
107
+ mkdirSync(distDir, { recursive: true })
108
+
109
+ // Create a simple index.js output
110
+ const srcIndexPath = join(cwd, 'src/index.ts')
111
+ if (existsSync(srcIndexPath)) {
112
+ const content = readFileSync(srcIndexPath, 'utf-8')
113
+ writeFileSync(join(distDir, 'index.js'), `// Compiled from TypeScript\n${content}`)
114
+ }
115
+
116
+ return { code: 0, stdout: 'Build succeeded', stderr: '' }
117
+ }
118
+
119
+ describe('Build Fingerprint Integration Tests (Issue #135)', () => {
120
+ test('T1: Fingerprint stored inside dist folder', async () => {
121
+ setupMonorepo()
122
+
123
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
124
+
125
+ // Verify fingerprint is inside dist folder
126
+ const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
127
+ expect(existsSync(fpPath)).toBe(true)
128
+
129
+ // Verify old location is NOT used
130
+ const oldPath = join(tmpDir, '.proman/build/@test-pkg.fingerprint')
131
+ expect(existsSync(oldPath)).toBe(false)
132
+
133
+ // .proman directory should not be created
134
+ expect(existsSync(join(tmpDir, '.proman'))).toBe(false)
135
+ })
136
+
137
+ test('T2: Removing dist invalidates fingerprint and forces rebuild', async () => {
138
+ setupMonorepo()
139
+ const logs: string[] = []
140
+ const originalLog = console.log
141
+ console.log = (msg: string) => {
142
+ logs.push(msg)
143
+ originalLog(msg)
144
+ }
145
+
146
+ try {
147
+ // First build
148
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
149
+
150
+ const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
151
+ expect(existsSync(fpPath)).toBe(true)
152
+
153
+ // Clear logs
154
+ logs.length = 0
155
+
156
+ // Second build without changes - should skip
157
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
158
+ expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(true)
159
+
160
+ // Remove dist folder
161
+ rmSync(join(tmpDir, 'packages/pkg/dist'), { recursive: true })
162
+
163
+ // Clear logs
164
+ logs.length = 0
165
+
166
+ // Third build - should rebuild (dist is gone)
167
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
168
+
169
+ // Should NOT skip (no skip log)
170
+ expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(false)
171
+
172
+ // New fingerprint written
173
+ expect(existsSync(fpPath)).toBe(true)
174
+ } finally {
175
+ console.log = originalLog
176
+ }
177
+ })
178
+
179
+ test('T3: Unchanged source with intact dist skips build', async () => {
180
+ setupMonorepo()
181
+ const logs: string[] = []
182
+ const originalLog = console.log
183
+ console.log = (msg: string) => {
184
+ logs.push(msg)
185
+ originalLog(msg)
186
+ }
187
+
188
+ try {
189
+ // First build
190
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
191
+
192
+ const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
193
+ const fpBefore = readFileSync(fpPath, 'utf-8')
194
+
195
+ // Clear logs
196
+ logs.length = 0
197
+
198
+ // Second build without changes - should skip
199
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
200
+ expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(true)
201
+
202
+ // Fingerprint unchanged
203
+ const fpAfter = readFileSync(fpPath, 'utf-8')
204
+ expect(fpAfter).toBe(fpBefore)
205
+ } finally {
206
+ console.log = originalLog
207
+ }
208
+ })
209
+
210
+ test('T4: Changed source invalidates fingerprint even with intact dist', async () => {
211
+ setupMonorepo()
212
+ const logs: string[] = []
213
+ const originalLog = console.log
214
+ console.log = (msg: string) => {
215
+ logs.push(msg)
216
+ originalLog(msg)
217
+ }
218
+
219
+ try {
220
+ // First build
221
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
222
+
223
+ const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
224
+ const fpBefore = readFileSync(fpPath, 'utf-8')
225
+
226
+ // Modify source
227
+ writeFileSync(join(tmpDir, 'packages/pkg/src/index.ts'), 'export const x = 999')
228
+
229
+ // Clear logs
230
+ logs.length = 0
231
+
232
+ // Second build with changed source - should rebuild
233
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
234
+
235
+ // Should NOT skip
236
+ expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(false)
237
+
238
+ // New fingerprint written
239
+ const fpAfter = readFileSync(fpPath, 'utf-8')
240
+ expect(fpAfter).not.toBe(fpBefore)
241
+ } finally {
242
+ console.log = originalLog
243
+ }
244
+ })
245
+
246
+ test('T5: Multiple packages each have fingerprints in their own dist', async () => {
247
+ setupMultiPackageMonorepo()
248
+
249
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
250
+
251
+ // Each package has fingerprint in its own dist
252
+ expect(existsSync(join(tmpDir, 'packages/core/dist/.build-fingerprint'))).toBe(true)
253
+ expect(existsSync(join(tmpDir, 'packages/fs/dist/.build-fingerprint'))).toBe(true)
254
+ expect(existsSync(join(tmpDir, 'packages/cli/dist/.build-fingerprint'))).toBe(true)
255
+
256
+ // No fingerprints in .proman/build directory
257
+ expect(existsSync(join(tmpDir, '.proman/build'))).toBe(false)
258
+ })
259
+
260
+ test('T6: Dependency propagation still works with new location', async () => {
261
+ setupMultiPackageMonorepo()
262
+
263
+ // First build
264
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
265
+
266
+ const coreFpBefore = readFileSync(
267
+ join(tmpDir, 'packages/core/dist/.build-fingerprint'),
268
+ 'utf-8',
269
+ )
270
+ const fsFpBefore = readFileSync(join(tmpDir, 'packages/fs/dist/.build-fingerprint'), 'utf-8')
271
+ const cliFpBefore = readFileSync(join(tmpDir, 'packages/cli/dist/.build-fingerprint'), 'utf-8')
272
+
273
+ // Modify core's source
274
+ writeFileSync(join(tmpDir, 'packages/core/src/index.ts'), 'export const x = 999')
275
+
276
+ // Second build
277
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
278
+
279
+ // All packages should have new fingerprints (dependency propagation)
280
+ const coreFpAfter = readFileSync(join(tmpDir, 'packages/core/dist/.build-fingerprint'), 'utf-8')
281
+ const fsFpAfter = readFileSync(join(tmpDir, 'packages/fs/dist/.build-fingerprint'), 'utf-8')
282
+ const cliFpAfter = readFileSync(join(tmpDir, 'packages/cli/dist/.build-fingerprint'), 'utf-8')
283
+
284
+ expect(coreFpAfter).not.toBe(coreFpBefore)
285
+ expect(fsFpAfter).not.toBe(fsFpBefore)
286
+ expect(cliFpAfter).not.toBe(cliFpBefore)
287
+ })
288
+
289
+ test('T7: Force flag bypasses cache regardless of fingerprint location', async () => {
290
+ setupMonorepo()
291
+ const logs: string[] = []
292
+ const originalLog = console.log
293
+ console.log = (msg: string) => {
294
+ logs.push(msg)
295
+ originalLog(msg)
296
+ }
297
+
298
+ try {
299
+ // First build
300
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
301
+
302
+ const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
303
+ const _fpBefore = readFileSync(fpPath, 'utf-8')
304
+
305
+ // Clear logs
306
+ logs.length = 0
307
+
308
+ // Second build with force - should rebuild
309
+ await build({ cwd: tmpDir, force: true, spawn: mockSpawn })
310
+
311
+ // Should NOT skip
312
+ expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(false)
313
+
314
+ // Fingerprint is rewritten (even if content is the same)
315
+ expect(existsSync(fpPath)).toBe(true)
316
+ } finally {
317
+ console.log = originalLog
318
+ }
319
+ })
320
+
321
+ test('T8: CI mode always rebuilds and writes fingerprints', async () => {
322
+ setupMonorepo()
323
+ const logs: string[] = []
324
+ const originalLog = console.log
325
+ console.log = (msg: string) => {
326
+ logs.push(msg)
327
+ originalLog(msg)
328
+ }
329
+
330
+ try {
331
+ // First build with force=false (enable fingerprints)
332
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
333
+
334
+ const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
335
+ expect(existsSync(fpPath)).toBe(true)
336
+
337
+ // Clear logs
338
+ logs.length = 0
339
+
340
+ // Second build with force=true (simulates CI mode) - should rebuild
341
+ await build({ cwd: tmpDir, force: true, spawn: mockSpawn })
342
+
343
+ // Should NOT skip (CI always rebuilds)
344
+ expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(false)
345
+
346
+ // Fingerprint is still written (even in force mode)
347
+ expect(existsSync(fpPath)).toBe(true)
348
+ } finally {
349
+ console.log = originalLog
350
+ }
351
+ })
352
+
353
+ test('T9: Build failure does not write fingerprint', async () => {
354
+ setupMonorepo()
355
+
356
+ // Mock spawn that fails
357
+ const failingSpawn = async () => {
358
+ return { code: 1, stdout: '', stderr: 'Build failed' }
359
+ }
360
+
361
+ // Build should throw
362
+ await expect(build({ cwd: tmpDir, force: false, spawn: failingSpawn })).rejects.toThrow()
363
+
364
+ // No fingerprint written
365
+ const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
366
+ expect(existsSync(fpPath)).toBe(false)
367
+ })
368
+
369
+ test('T10: Partial dist deletion (missing fingerprint) forces rebuild', async () => {
370
+ setupMonorepo()
371
+ const logs: string[] = []
372
+ const originalLog = console.log
373
+ console.log = (msg: string) => {
374
+ logs.push(msg)
375
+ originalLog(msg)
376
+ }
377
+
378
+ try {
379
+ // First build
380
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
381
+
382
+ const fpPath = join(tmpDir, 'packages/pkg/dist/.build-fingerprint')
383
+ expect(existsSync(fpPath)).toBe(true)
384
+
385
+ // Delete only fingerprint (keep dist folder)
386
+ rmSync(fpPath)
387
+
388
+ // Clear logs
389
+ logs.length = 0
390
+
391
+ // Second build - should rebuild (missing fingerprint)
392
+ await build({ cwd: tmpDir, force: false, spawn: mockSpawn })
393
+
394
+ // Should NOT skip (no skip log)
395
+ expect(logs.some((log) => log.includes('⏭ build: @test/pkg (unchanged)'))).toBe(false)
396
+
397
+ // New fingerprint written
398
+ expect(existsSync(fpPath)).toBe(true)
399
+ } finally {
400
+ console.log = originalLog
401
+ }
402
+ })
403
+ })