infra-kit 0.1.102 → 0.1.105

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 (127) hide show
  1. package/.eslintcache +1 -1
  2. package/.omc/state/agent-replay-0a58307d-2a37-4c69-851c-83a646502d62.jsonl +1 -0
  3. package/.omc/state/agent-replay-11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc.jsonl +16 -0
  4. package/.omc/state/agent-replay-4cf1c186-81b2-497c-b002-d7f84e7839f3.jsonl +9 -0
  5. package/.omc/state/agent-replay-5c4ab554-64f1-42ae-83e3-21e0237e955c.jsonl +11 -0
  6. package/.omc/state/agent-replay-a60ac2ec-afbd-449f-a540-6df287392fc2.jsonl +1 -0
  7. package/.omc/state/agent-replay-be37e426-6fc8-47f4-8178-221c8494551c.jsonl +3 -0
  8. package/.omc/state/agent-replay-c967c819-3d1c-447b-ab48-56a8448ef9f8.jsonl +2 -0
  9. package/.omc/state/idle-notif-cooldown.json +3 -0
  10. package/.omc/state/last-tool-error.json +4 -4
  11. package/.omc/state/mission-state.json +53 -0
  12. package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
  13. package/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/subagent-tracking-state.json +7 -0
  14. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/last-tool-error-state.json +7 -0
  15. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/mission-state.json +117 -0
  16. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/pre-tool-advisory-throttle.json +42 -0
  17. package/.omc/state/sessions/11c41aa0-51fa-49e1-a1dc-26dcd6ac26cc/subagent-tracking-state.json +53 -0
  18. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/last-tool-error-state.json +7 -0
  19. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/pre-tool-advisory-throttle.json +18 -0
  20. package/.omc/state/sessions/4cf1c186-81b2-497c-b002-d7f84e7839f3/subagent-tracking-state.json +7 -0
  21. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/mission-state.json +117 -0
  22. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/pre-tool-advisory-throttle.json +18 -0
  23. package/.omc/state/sessions/5c4ab554-64f1-42ae-83e3-21e0237e955c/subagent-tracking-state.json +17 -0
  24. package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +18 -0
  25. package/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/subagent-tracking-state.json +7 -0
  26. package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +10 -0
  27. package/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/subagent-tracking-state.json +7 -0
  28. package/.omc/state/subagent-tracking.json +14 -4
  29. package/.turbo/turbo-build.log +7 -0
  30. package/.turbo/turbo-check.log +14 -0
  31. package/.turbo/turbo-prettier-fix.log +2 -1
  32. package/.turbo/turbo-test.log +28 -5
  33. package/.turbo/turbo-validate.log +14 -0
  34. package/dist/cli.js +81 -74
  35. package/dist/cli.js.map +4 -4
  36. package/dist/entry/index.d.ts +2 -0
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +7 -0
  39. package/dist/lib/package-config/package-config.d.ts +71 -0
  40. package/dist/mcp.js +43 -41
  41. package/dist/mcp.js.map +4 -4
  42. package/eslint.config.js +1 -1
  43. package/infra-kit.config.ts +5 -0
  44. package/package.json +20 -13
  45. package/scripts/build.js +32 -3
  46. package/src/.omc/state/sessions/0a58307d-2a37-4c69-851c-83a646502d62/pre-tool-advisory-throttle.json +18 -0
  47. package/src/commands/.omc/state/sessions/c967c819-3d1c-447b-ab48-56a8448ef9f8/pre-tool-advisory-throttle.json +18 -0
  48. package/src/commands/audit/__tests__/audit.test.ts +59 -0
  49. package/src/commands/audit/audit.ts +177 -0
  50. package/src/commands/audit/index.ts +1 -0
  51. package/src/commands/config/config.ts +49 -7
  52. package/src/commands/doctor/doctor.ts +3 -3
  53. package/src/commands/env-clear/env-clear.ts +1 -1
  54. package/src/commands/env-list/env-list.ts +3 -3
  55. package/src/commands/env-load/env-load.ts +1 -1
  56. package/src/commands/env-status/env-status.ts +1 -1
  57. package/src/commands/gh-merge-dev/gh-merge-dev.ts +3 -8
  58. package/src/commands/gh-release-deliver/gh-release-deliver.ts +47 -21
  59. package/src/commands/gh-release-deploy-all/gh-release-deploy-all.ts +13 -7
  60. package/src/commands/gh-release-deploy-selected/gh-release-deploy-selected.ts +12 -6
  61. package/src/commands/gh-release-list/gh-release-list.ts +19 -8
  62. package/src/commands/init/__tests__/migrate-config.test.ts +160 -0
  63. package/src/commands/init/init.ts +48 -35
  64. package/src/commands/init/migrate-config.ts +146 -0
  65. package/src/commands/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  66. package/src/commands/release-create/__tests__/release-create.test.ts +55 -0
  67. package/src/commands/release-create/release-create.ts +142 -38
  68. package/src/commands/release-desc-edit/release-desc-edit.ts +28 -8
  69. package/src/commands/version/version.ts +1 -1
  70. package/src/commands/worktrees-add/worktrees-add.ts +7 -12
  71. package/src/commands/worktrees-list/worktrees-list.ts +13 -5
  72. package/src/commands/worktrees-open/worktrees-open.ts +1 -1
  73. package/src/commands/worktrees-remove/worktrees-remove.ts +6 -10
  74. package/src/commands/worktrees-sync/worktrees-sync.ts +3 -5
  75. package/src/entry/cli.ts +49 -6
  76. package/src/entry/index.ts +5 -0
  77. package/src/integrations/cmux/open-workspace-with-layout.ts +4 -4
  78. package/src/integrations/cmux/workspace-title.ts +10 -4
  79. package/src/integrations/doppler/doppler-project.ts +1 -1
  80. package/src/integrations/gh/gh-release-prs/__tests__/gh-release-prs.test.ts +115 -0
  81. package/src/integrations/gh/gh-release-prs/gh-release-prs.ts +49 -32
  82. package/src/lib/.omc/state/sessions/a60ac2ec-afbd-449f-a540-6df287392fc2/pre-tool-advisory-throttle.json +14 -0
  83. package/src/lib/constants/index.ts +15 -0
  84. package/src/lib/git-utils/__tests__/git-utils.test.ts +49 -0
  85. package/src/lib/git-utils/git-utils.ts +3 -1
  86. package/src/lib/infra-kit-config/__tests__/infra-kit-config.test.ts +270 -0
  87. package/src/lib/infra-kit-config/index.ts +7 -1
  88. package/src/lib/infra-kit-config/infra-kit-config.ts +46 -28
  89. package/src/lib/package-config/__tests__/package-config.test.ts +95 -0
  90. package/src/lib/package-config/index.ts +3 -0
  91. package/src/lib/package-config/package-config-schema.ts +19 -0
  92. package/src/lib/package-config/package-config.ts +99 -0
  93. package/src/lib/package-validator/__tests__/package-validator.test.ts +263 -0
  94. package/src/lib/package-validator/checks/__tests__/checks.test.ts +130 -0
  95. package/src/lib/package-validator/checks/config-check.ts +30 -0
  96. package/src/lib/package-validator/checks/files-check.ts +29 -0
  97. package/src/lib/package-validator/checks/index.ts +4 -0
  98. package/src/lib/package-validator/checks/scripts-check.ts +23 -0
  99. package/src/lib/package-validator/checks/turbo-check.ts +47 -0
  100. package/src/lib/package-validator/fs-utils.ts +18 -0
  101. package/src/lib/package-validator/index.ts +3 -0
  102. package/src/lib/package-validator/loader/config-loader.ts +77 -0
  103. package/src/lib/package-validator/loader/index.ts +2 -0
  104. package/src/lib/package-validator/loader/package-discovery.ts +98 -0
  105. package/src/lib/package-validator/package-validator.ts +48 -0
  106. package/src/lib/package-validator/types.ts +15 -0
  107. package/src/lib/release-id/__tests__/release-id.test.ts +351 -0
  108. package/src/lib/release-id/__tests__/versioned-regression.test.ts +69 -0
  109. package/src/lib/release-id/index.ts +15 -0
  110. package/src/lib/release-id/release-id.ts +257 -0
  111. package/src/lib/release-utils/__tests__/release-utils.test.ts +122 -0
  112. package/src/lib/release-utils/index.ts +4 -0
  113. package/src/lib/release-utils/release-utils.ts +85 -17
  114. package/src/lib/version-utils/__tests__/load-existing-versions.test.ts +37 -0
  115. package/src/lib/version-utils/__tests__/next-version.test.ts +119 -13
  116. package/src/lib/version-utils/index.ts +3 -0
  117. package/src/lib/version-utils/load-existing-versions.ts +29 -10
  118. package/src/lib/version-utils/next-version.ts +67 -12
  119. package/src/lib/version-utils/version-utils.ts +13 -4
  120. package/src/mcp/tools/index.ts +2 -0
  121. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  122. package/src/types.ts +1 -1
  123. package/tsconfig.tsbuildinfo +1 -1
  124. package/src/lib/__tests__/infra-kit-config.test.ts +0 -231
  125. /package/src/integrations/{clickup → linear}/.gitkeep +0 -0
  126. /package/src/lib/{__tests__ → constants/__tests__}/constants.test.ts +0 -0
  127. /package/src/lib/{constants.ts → constants/constants.ts} +0 -0
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Validation rules for a single workspace package, declared in its
3
+ * `infra-kit.config.js`. Every field is optional: a key left unset falls back to
4
+ * the active baseline ({@link DEFAULT_RULES} for packages, {@link ROOT_DEFAULT_RULES}
5
+ * for the monorepo root), and a key set replaces that default wholesale (per-key,
6
+ * no array concatenation) so a package can opt out with an explicit empty array.
7
+ *
8
+ * Most packages need none of these — the standard rules live in the baseline, so
9
+ * a typical config is just `defineConfig(() => ({}))`.
10
+ *
11
+ * @example
12
+ * // infra-kit.config.js
13
+ * import { defineConfig } from 'infra-kit'
14
+ *
15
+ * export default defineConfig(() => ({}))
16
+ */
17
+ export interface InfraKitPackageConfig {
18
+ /** Scripts that must be present in the package's package.json `scripts` map. */
19
+ requiredScripts?: string[]
20
+ /** Files (relative to the package root) that must exist on disk. */
21
+ requiredFiles?: string[]
22
+ /** Turborepo expectations — only meaningful where a turbo.json lives (the root). */
23
+ turbo?: {
24
+ /** Tasks that must be defined in turbo.json `tasks`. */
25
+ requiredTasks?: string[]
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Accepted shapes for a package config's default export — mirrors Vite's
31
+ * `defineConfig` input: a plain object, a sync factory, or an async factory.
32
+ */
33
+ export type InfraKitPackageConfigInput =
34
+ | InfraKitPackageConfig
35
+ | (() => InfraKitPackageConfig)
36
+ | (() => Promise<InfraKitPackageConfig>)
37
+
38
+ /**
39
+ * Identity helper that gives `infra-kit.config.js` authors full type inference
40
+ * and editor autocomplete without changing the value — exactly like Vite's
41
+ * `defineConfig`. Resolution of the factory form happens in the loader, not here.
42
+ *
43
+ * @example
44
+ * export default defineConfig(() => ({}))
45
+ *
46
+ * @example
47
+ * export default defineConfig(() => ({ requiredScripts: [] }))
48
+ */
49
+ export const defineConfig = (config: InfraKitPackageConfigInput): InfraKitPackageConfigInput => {
50
+ return config
51
+ }
52
+
53
+ /** Fully-resolved rules with every defaultable field present. */
54
+ export interface ResolvedPackageRules {
55
+ requiredScripts: string[]
56
+ requiredFiles: string[]
57
+ turboTasks: string[]
58
+ }
59
+
60
+ /**
61
+ * Baseline rules for a standard TypeScript workspace package, applied to any key
62
+ * a package leaves unset. These are the "under the hood" defaults so a conforming
63
+ * package's config can stay empty; non-standard packages override the relevant key.
64
+ */
65
+ export const DEFAULT_RULES: Readonly<ResolvedPackageRules> = {
66
+ requiredScripts: ['build', 'ts-check', 'eslint-check', 'prettier-check', 'test'],
67
+ requiredFiles: ['tsconfig.json', 'eslint.config.js', 'readme.md'],
68
+ turboTasks: [],
69
+ }
70
+
71
+ /**
72
+ * Baseline rules for the monorepo root (`infra-kit audit --root`). Checks the
73
+ * root commands, the workspace/turbo files, and that the turbo pipeline defines
74
+ * the expected tasks — so the root's own config can also stay empty.
75
+ */
76
+ export const ROOT_DEFAULT_RULES: Readonly<ResolvedPackageRules> = {
77
+ requiredScripts: ['build', 'dev', 'test', 'qa', 'check', 'fix'],
78
+ requiredFiles: ['turbo.json', 'pnpm-workspace.yaml'],
79
+ turboTasks: ['build', 'test', 'ts-check', 'eslint-check', 'prettier-check', 'check'],
80
+ }
81
+
82
+ /**
83
+ * Merge a parsed package config over a baseline. Each key is replaced wholesale
84
+ * when the package provides it, otherwise the baseline value is used.
85
+ *
86
+ * @example
87
+ * resolvePackageConfig({ requiredScripts: [] })
88
+ * // => { requiredScripts: [], requiredFiles: [...DEFAULT_RULES.requiredFiles], turboTasks: [] }
89
+ */
90
+ export const resolvePackageConfig = (
91
+ config: InfraKitPackageConfig,
92
+ baseline: Readonly<ResolvedPackageRules> = DEFAULT_RULES,
93
+ ): ResolvedPackageRules => {
94
+ return {
95
+ requiredScripts: config.requiredScripts ?? [...baseline.requiredScripts],
96
+ requiredFiles: config.requiredFiles ?? [...baseline.requiredFiles],
97
+ turboTasks: config.turbo?.requiredTasks ?? [...baseline.turboTasks],
98
+ }
99
+ }
@@ -0,0 +1,263 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import { DEFAULT_RULES, ROOT_DEFAULT_RULES } from 'src/lib/package-config'
7
+
8
+ import { discoverPackages, loadPackageConfig, validatePackage } from '../package-validator'
9
+
10
+ const tmpDirs: string[] = []
11
+
12
+ const makeTmpDir = (): string => {
13
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pkg-validator-'))
14
+
15
+ tmpDirs.push(dir)
16
+
17
+ return dir
18
+ }
19
+
20
+ interface PackageFixture {
21
+ packageJson?: Record<string, unknown>
22
+ config?: string
23
+ files?: Record<string, string>
24
+ }
25
+
26
+ const writePackage = (dir: string, fixture: PackageFixture): void => {
27
+ const packageJson = fixture.packageJson ?? { name: '@x/pkg', type: 'module' }
28
+
29
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify(packageJson, null, 2))
30
+
31
+ if (fixture.config !== undefined) {
32
+ fs.writeFileSync(path.join(dir, 'infra-kit.config.ts'), fixture.config)
33
+ }
34
+
35
+ for (const [name, content] of Object.entries(fixture.files ?? {})) {
36
+ fs.writeFileSync(path.join(dir, name), content)
37
+ }
38
+ }
39
+
40
+ afterEach(() => {
41
+ while (tmpDirs.length > 0) {
42
+ const dir = tmpDirs.pop()
43
+
44
+ if (dir) {
45
+ fs.rmSync(dir, { recursive: true, force: true })
46
+ }
47
+ }
48
+ })
49
+
50
+ describe('loadPackageConfig', () => {
51
+ it('throws when infra-kit.config.js is missing', async () => {
52
+ const dir = makeTmpDir()
53
+
54
+ writePackage(dir, {})
55
+
56
+ await expect(loadPackageConfig(dir)).rejects.toThrow(/not found/)
57
+ })
58
+
59
+ it('loads an object default export and merges defaults', async () => {
60
+ const dir = makeTmpDir()
61
+
62
+ writePackage(dir, { config: 'export default { requiredScripts: [] }' })
63
+
64
+ const rules = await loadPackageConfig(dir)
65
+
66
+ expect(rules.requiredScripts).toEqual([])
67
+ expect(rules.requiredFiles).toEqual(DEFAULT_RULES.requiredFiles)
68
+ })
69
+
70
+ it('resolves a factory (function) default export', async () => {
71
+ const dir = makeTmpDir()
72
+
73
+ writePackage(dir, { config: "export default () => ({ requiredFiles: ['a.txt'] })" })
74
+
75
+ const rules = await loadPackageConfig(dir)
76
+
77
+ expect(rules.requiredFiles).toEqual(['a.txt'])
78
+ })
79
+
80
+ it('rejects an invalid config shape with a descriptive error', async () => {
81
+ const dir = makeTmpDir()
82
+
83
+ writePackage(dir, { config: "export default { requiredScripts: 'build' }" })
84
+
85
+ await expect(loadPackageConfig(dir)).rejects.toThrow(/Invalid/)
86
+ })
87
+
88
+ it('rejects an unknown key (typo protection)', async () => {
89
+ const dir = makeTmpDir()
90
+
91
+ writePackage(dir, { config: 'export default { requiredScript: [] }' })
92
+
93
+ await expect(loadPackageConfig(dir)).rejects.toThrow(/Invalid/)
94
+ })
95
+ })
96
+
97
+ describe('validatePackage', () => {
98
+ it('passes when config, required scripts, and required files are all satisfied', async () => {
99
+ const dir = makeTmpDir()
100
+
101
+ writePackage(dir, {
102
+ packageJson: { name: '@x/ok', type: 'module', scripts: { build: 'x' } },
103
+ config: "export default { requiredScripts: ['build'], requiredFiles: ['tsconfig.json'] }",
104
+ files: { 'tsconfig.json': '{}' },
105
+ })
106
+
107
+ const result = await validatePackage(dir)
108
+
109
+ expect(result.passed).toBe(true)
110
+ expect(result.packageName).toBe('@x/ok')
111
+ })
112
+
113
+ it('applies the under-the-hood defaults when the config is empty', async () => {
114
+ const dir = makeTmpDir()
115
+
116
+ writePackage(dir, {
117
+ packageJson: {
118
+ name: '@x/std',
119
+ type: 'module',
120
+ scripts: { build: 'x', 'ts-check': 'x', 'eslint-check': 'x', 'prettier-check': 'x', test: 'x' },
121
+ },
122
+ config: 'export default {}',
123
+ files: { 'tsconfig.json': '{}', 'eslint.config.js': '', 'readme.md': '' },
124
+ })
125
+
126
+ const result = await validatePackage(dir)
127
+
128
+ expect(result.passed).toBe(true)
129
+ })
130
+
131
+ it('fails when a required script is missing', async () => {
132
+ const dir = makeTmpDir()
133
+
134
+ writePackage(dir, {
135
+ packageJson: { name: '@x/no-script', type: 'module', scripts: { build: 'x' } },
136
+ config: "export default { requiredScripts: ['build', 'ts-check'], requiredFiles: [] }",
137
+ })
138
+
139
+ const result = await validatePackage(dir)
140
+
141
+ expect(result.passed).toBe(false)
142
+ expect(result.checks).toContainEqual(expect.objectContaining({ name: 'script:ts-check', status: 'fail' }))
143
+ })
144
+
145
+ it('fails when a required file is missing', async () => {
146
+ const dir = makeTmpDir()
147
+
148
+ writePackage(dir, {
149
+ packageJson: { name: '@x/no-file', type: 'module', scripts: {} },
150
+ config: "export default { requiredScripts: [], requiredFiles: ['tsconfig.json'] }",
151
+ })
152
+
153
+ const result = await validatePackage(dir)
154
+
155
+ expect(result.passed).toBe(false)
156
+ expect(result.checks).toContainEqual(expect.objectContaining({ name: 'file:tsconfig.json', status: 'fail' }))
157
+ })
158
+
159
+ it('fails with only the config check when infra-kit.config.ts is missing', async () => {
160
+ const dir = makeTmpDir()
161
+
162
+ writePackage(dir, { packageJson: { name: '@x/no-config', type: 'module' } })
163
+
164
+ const result = await validatePackage(dir)
165
+
166
+ expect(result.passed).toBe(false)
167
+ expect(result.checks).toHaveLength(1)
168
+ expect(result.checks[0]).toMatchObject({ name: 'infra-kit.config.ts', status: 'fail' })
169
+ })
170
+ })
171
+
172
+ describe('validatePackage — root / turbo', () => {
173
+ it('passes the root baseline when scripts, files, and turbo tasks are present', async () => {
174
+ const dir = makeTmpDir()
175
+
176
+ writePackage(dir, {
177
+ packageJson: {
178
+ name: 'monorepo',
179
+ type: 'module',
180
+ scripts: { build: 'x', dev: 'x', test: 'x', qa: 'x', check: 'x', fix: 'x' },
181
+ },
182
+ config: 'export default {}',
183
+ files: {
184
+ 'pnpm-workspace.yaml': 'packages: []\n',
185
+ 'turbo.json': JSON.stringify({
186
+ tasks: { build: {}, test: {}, 'ts-check': {}, 'eslint-check': {}, 'prettier-check': {}, check: {} },
187
+ }),
188
+ },
189
+ })
190
+
191
+ const result = await validatePackage(dir, ROOT_DEFAULT_RULES)
192
+
193
+ expect(result.passed).toBe(true)
194
+ })
195
+
196
+ it('fails when a required turbo task is missing from turbo.json', async () => {
197
+ const dir = makeTmpDir()
198
+
199
+ writePackage(dir, {
200
+ packageJson: { name: 'monorepo', type: 'module' },
201
+ config: "export default { requiredScripts: [], requiredFiles: [], turbo: { requiredTasks: ['build', 'lint'] } }",
202
+ files: { 'turbo.json': JSON.stringify({ tasks: { build: {} } }) },
203
+ })
204
+
205
+ const result = await validatePackage(dir)
206
+
207
+ expect(result.passed).toBe(false)
208
+ expect(result.checks).toContainEqual(expect.objectContaining({ name: 'turbo:lint', status: 'fail' }))
209
+ expect(result.checks).toContainEqual(expect.objectContaining({ name: 'turbo:build', status: 'pass' }))
210
+ })
211
+
212
+ it('accepts a root task keyed as //#name in turbo.json', async () => {
213
+ const dir = makeTmpDir()
214
+
215
+ writePackage(dir, {
216
+ packageJson: { name: 'monorepo', type: 'module' },
217
+ config: "export default { requiredScripts: [], requiredFiles: [], turbo: { requiredTasks: ['check-root'] } }",
218
+ files: { 'turbo.json': JSON.stringify({ tasks: { '//#check-root': {} } }) },
219
+ })
220
+
221
+ const result = await validatePackage(dir)
222
+
223
+ expect(result.checks).toContainEqual(expect.objectContaining({ name: 'turbo:check-root', status: 'pass' }))
224
+ })
225
+ })
226
+
227
+ describe('discoverPackages', () => {
228
+ it('expands non-vendor globs and excludes vendor and negations', async () => {
229
+ const root = makeTmpDir()
230
+
231
+ fs.writeFileSync(
232
+ path.join(root, 'pnpm-workspace.yaml'),
233
+ 'packages:\n - apps/*/*\n - packages/*\n - vendor/packages/*\n - "!**/test/**"\n',
234
+ )
235
+
236
+ const dirs = ['apps/infra-kit/cli', 'packages/p1', 'vendor/packages/v1']
237
+
238
+ for (const dir of dirs) {
239
+ const full = path.join(root, dir)
240
+
241
+ fs.mkdirSync(full, { recursive: true })
242
+ fs.writeFileSync(path.join(full, 'package.json'), '{}')
243
+ }
244
+
245
+ const found = await discoverPackages(root)
246
+
247
+ expect(found).toContain(path.join(root, 'apps/infra-kit/cli'))
248
+ expect(found).toContain(path.join(root, 'packages/p1'))
249
+ expect(found).not.toContain(path.join(root, 'vendor/packages/v1'))
250
+ })
251
+
252
+ it('omits directories that lack a package.json', async () => {
253
+ const root = makeTmpDir()
254
+
255
+ fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\n')
256
+
257
+ fs.mkdirSync(path.join(root, 'packages/empty'), { recursive: true })
258
+
259
+ const found = await discoverPackages(root)
260
+
261
+ expect(found).not.toContain(path.join(root, 'packages/empty'))
262
+ })
263
+ })
@@ -0,0 +1,130 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import { checkFiles } from '../files-check'
7
+ import { checkScripts } from '../scripts-check'
8
+ import { checkTurbo } from '../turbo-check'
9
+
10
+ const tmpDirs: string[] = []
11
+
12
+ const makeTmpDir = (): string => {
13
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'checks-'))
14
+
15
+ tmpDirs.push(dir)
16
+
17
+ return dir
18
+ }
19
+
20
+ afterEach(() => {
21
+ while (tmpDirs.length > 0) {
22
+ const dir = tmpDirs.pop()
23
+
24
+ if (dir) {
25
+ fs.rmSync(dir, { recursive: true, force: true })
26
+ }
27
+ }
28
+ })
29
+
30
+ describe('checkScripts', () => {
31
+ it('passes a script defined with a runnable command', () => {
32
+ const checks = checkScripts({ build: 'tsc' }, ['build'])
33
+
34
+ expect(checks).toEqual([{ name: 'script:build', status: 'pass', message: 'defined' }])
35
+ })
36
+
37
+ it('fails a script that is absent from the scripts map', () => {
38
+ const checks = checkScripts({}, ['build'])
39
+
40
+ expect(checks[0]).toMatchObject({ name: 'script:build', status: 'fail' })
41
+ expect(checks[0]?.message).toContain('missing')
42
+ })
43
+
44
+ it('fails a script declared with an empty value', () => {
45
+ const checks = checkScripts({ build: '' }, ['build'])
46
+
47
+ expect(checks[0]).toMatchObject({ name: 'script:build', status: 'fail' })
48
+ expect(checks[0]?.message).toContain('empty')
49
+ })
50
+
51
+ it('fails a script declared with a whitespace-only value', () => {
52
+ const checks = checkScripts({ build: ' ' }, ['build'])
53
+
54
+ expect(checks[0]).toMatchObject({ name: 'script:build', status: 'fail' })
55
+ expect(checks[0]?.message).toContain('empty')
56
+ })
57
+ })
58
+
59
+ describe('checkFiles', () => {
60
+ it('passes a required path that is a regular file', async () => {
61
+ const dir = makeTmpDir()
62
+
63
+ fs.writeFileSync(path.join(dir, 'readme.md'), '# hi')
64
+
65
+ const checks = await checkFiles(dir, ['readme.md'])
66
+
67
+ expect(checks[0]).toMatchObject({ name: 'file:readme.md', status: 'pass' })
68
+ })
69
+
70
+ it('fails a required file that does not exist', async () => {
71
+ const dir = makeTmpDir()
72
+
73
+ const checks = await checkFiles(dir, ['readme.md'])
74
+
75
+ expect(checks[0]).toMatchObject({ name: 'file:readme.md', status: 'fail' })
76
+ expect(checks[0]?.message).toContain('missing file')
77
+ })
78
+
79
+ it('fails a required path that exists but is a directory', async () => {
80
+ const dir = makeTmpDir()
81
+
82
+ fs.mkdirSync(path.join(dir, 'readme.md'))
83
+
84
+ const checks = await checkFiles(dir, ['readme.md'])
85
+
86
+ expect(checks[0]).toMatchObject({ name: 'file:readme.md', status: 'fail' })
87
+ expect(checks[0]?.message).toContain('not a file')
88
+ })
89
+ })
90
+
91
+ describe('checkTurbo', () => {
92
+ it('returns no checks when no turbo tasks are required', async () => {
93
+ const dir = makeTmpDir()
94
+
95
+ expect(await checkTurbo(dir, [])).toEqual([])
96
+ })
97
+
98
+ it('fails with a single diagnostic when turbo.json cannot be read', async () => {
99
+ const dir = makeTmpDir()
100
+
101
+ const checks = await checkTurbo(dir, ['build'])
102
+
103
+ expect(checks).toHaveLength(1)
104
+ expect(checks[0]).toMatchObject({ name: 'turbo.json', status: 'fail' })
105
+ expect(checks[0]?.message).toContain('cannot read/parse')
106
+ })
107
+
108
+ it('fails with a single diagnostic when turbo.json has no tasks object', async () => {
109
+ const dir = makeTmpDir()
110
+
111
+ fs.writeFileSync(path.join(dir, 'turbo.json'), JSON.stringify({ $schema: 'x' }))
112
+
113
+ const checks = await checkTurbo(dir, ['build', 'test'])
114
+
115
+ expect(checks).toHaveLength(1)
116
+ expect(checks[0]).toMatchObject({ name: 'turbo.json', status: 'fail' })
117
+ expect(checks[0]?.message).toContain('no "tasks" object')
118
+ })
119
+
120
+ it('reports per-task results when a tasks object exists', async () => {
121
+ const dir = makeTmpDir()
122
+
123
+ fs.writeFileSync(path.join(dir, 'turbo.json'), JSON.stringify({ tasks: { build: {} } }))
124
+
125
+ const checks = await checkTurbo(dir, ['build', 'test'])
126
+
127
+ expect(checks).toContainEqual(expect.objectContaining({ name: 'turbo:build', status: 'pass' }))
128
+ expect(checks).toContainEqual(expect.objectContaining({ name: 'turbo:test', status: 'fail' }))
129
+ })
130
+ })
@@ -0,0 +1,30 @@
1
+ import { DEFAULT_RULES } from 'src/lib/package-config'
2
+ import type { ResolvedPackageRules } from 'src/lib/package-config'
3
+
4
+ import { PACKAGE_CONFIG_FILE, loadPackageConfig } from '../loader'
5
+ import type { PackageCheck } from '../types'
6
+
7
+ /**
8
+ * Build the "config present and valid" check, returning the resolved rules when
9
+ * the load succeeds so the caller can run the rule-based checks against them.
10
+ * When the config fails to load the rules are `null` and the caller skips the
11
+ * rule-based checks (the expectations are unknown).
12
+ */
13
+ export const checkConfig = async (
14
+ packageDir: string,
15
+ baseline: Readonly<ResolvedPackageRules> = DEFAULT_RULES,
16
+ ): Promise<{ check: PackageCheck; rules: ResolvedPackageRules | null }> => {
17
+ try {
18
+ const rules = await loadPackageConfig(packageDir, baseline)
19
+
20
+ return {
21
+ check: { name: PACKAGE_CONFIG_FILE, status: 'pass', message: 'present and valid' },
22
+ rules,
23
+ }
24
+ } catch (err) {
25
+ return {
26
+ check: { name: PACKAGE_CONFIG_FILE, status: 'fail', message: (err as Error).message },
27
+ rules: null,
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,29 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import type { PackageCheck } from '../types'
5
+
6
+ /**
7
+ * Check that every required file exists relative to the package root AND is a
8
+ * regular file. A directory that happens to share the required name fails — a
9
+ * required `readme.md` must be the file, not a folder.
10
+ */
11
+ export const checkFiles = async (packageDir: string, requiredFiles: string[]): Promise<PackageCheck[]> => {
12
+ return Promise.all(
13
+ requiredFiles.map(async (file) => {
14
+ const stat = await fs.stat(path.join(packageDir, file)).catch(() => {
15
+ return null
16
+ })
17
+
18
+ if (!stat) {
19
+ return { name: `file:${file}`, status: 'fail' as const, message: `missing file: ${file}` }
20
+ }
21
+
22
+ if (!stat.isFile()) {
23
+ return { name: `file:${file}`, status: 'fail' as const, message: `not a file: ${file} (found a directory)` }
24
+ }
25
+
26
+ return { name: `file:${file}`, status: 'pass' as const, message: 'exists' }
27
+ }),
28
+ )
29
+ }
@@ -0,0 +1,4 @@
1
+ export { checkConfig } from './config-check'
2
+ export { checkFiles } from './files-check'
3
+ export { checkScripts } from './scripts-check'
4
+ export { checkTurbo } from './turbo-check'
@@ -0,0 +1,23 @@
1
+ import type { PackageCheck } from '../types'
2
+
3
+ /**
4
+ * Check that every required script is present in the package.json `scripts` map
5
+ * and carries a runnable command. A key declared with an empty or whitespace-only
6
+ * value fails as well — an empty script silently no-ops in CI, so presence alone
7
+ * is not enough.
8
+ */
9
+ export const checkScripts = (scripts: Record<string, string>, requiredScripts: string[]): PackageCheck[] => {
10
+ return requiredScripts.map((script) => {
11
+ const value = scripts[script]
12
+
13
+ if (typeof value !== 'string') {
14
+ return { name: `script:${script}`, status: 'fail', message: `missing "${script}" in package.json scripts` }
15
+ }
16
+
17
+ if (value.trim().length === 0) {
18
+ return { name: `script:${script}`, status: 'fail', message: `"${script}" is empty in package.json scripts` }
19
+ }
20
+
21
+ return { name: `script:${script}`, status: 'pass', message: 'defined' }
22
+ })
23
+ }
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import type { PackageCheck } from '../types'
5
+
6
+ const TURBO_FILE = 'turbo.json'
7
+
8
+ /**
9
+ * Check that every required turbo task is defined in turbo.json `tasks`. A root
10
+ * task may be keyed as either `name` or `//#name`, so both forms count as present.
11
+ * Runs only when the resolved rules ask for turbo tasks (the monorepo root).
12
+ *
13
+ * Reports a single clear diagnostic when turbo.json is unreadable or carries no
14
+ * `tasks` object, rather than emitting one identical "missing task" line per
15
+ * required task.
16
+ */
17
+ export const checkTurbo = async (packageDir: string, requiredTasks: string[]): Promise<PackageCheck[]> => {
18
+ if (requiredTasks.length === 0) {
19
+ return []
20
+ }
21
+
22
+ let parsed: { tasks?: Record<string, unknown> }
23
+
24
+ try {
25
+ const raw = await fs.readFile(path.join(packageDir, TURBO_FILE), 'utf-8')
26
+
27
+ parsed = JSON.parse(raw) as { tasks?: Record<string, unknown> }
28
+ } catch (err) {
29
+ return [{ name: TURBO_FILE, status: 'fail', message: `cannot read/parse ${TURBO_FILE}: ${(err as Error).message}` }]
30
+ }
31
+
32
+ const tasks = parsed.tasks
33
+
34
+ if (tasks === null || typeof tasks !== 'object') {
35
+ return [{ name: TURBO_FILE, status: 'fail', message: `no "tasks" object defined in ${TURBO_FILE}` }]
36
+ }
37
+
38
+ return requiredTasks.map((task) => {
39
+ const defined = task in tasks || `//#${task}` in tasks
40
+
41
+ return {
42
+ name: `turbo:${task}`,
43
+ status: defined ? 'pass' : 'fail',
44
+ message: defined ? 'defined' : `missing turbo task "${task}" in ${TURBO_FILE}`,
45
+ }
46
+ })
47
+ }
@@ -0,0 +1,18 @@
1
+ import fs from 'node:fs/promises'
2
+
3
+ /**
4
+ * Resolve whether a path is reachable, suppressing ENOENT into a boolean.
5
+ *
6
+ * @example
7
+ * await pathExists('/etc/hosts') // => true
8
+ * await pathExists('/nope') // => false
9
+ */
10
+ export const pathExists = async (target: string): Promise<boolean> => {
11
+ try {
12
+ await fs.access(target)
13
+
14
+ return true
15
+ } catch {
16
+ return false
17
+ }
18
+ }
@@ -0,0 +1,3 @@
1
+ export { pathExists } from './fs-utils'
2
+ export { discoverPackages, loadPackageConfig, validatePackage } from './package-validator'
3
+ export type { PackageCheck, PackageValidationResult } from './package-validator'