proteum 2.5.1 → 2.5.3
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/AGENTS.md +3 -3
- package/README.md +12 -9
- package/agents/project/AGENTS.md +10 -8
- package/agents/project/CODING_STYLE.md +1 -1
- package/agents/project/diagnostics.md +2 -2
- package/agents/project/root/AGENTS.md +10 -8
- package/agents/project/tests/AGENTS.md +1 -1
- package/cli/commands/configure.ts +5 -5
- package/cli/commands/dev.ts +3 -3
- package/cli/commands/verify.ts +117 -4
- package/cli/compiler/artifacts/controllerHelper.ts +66 -0
- package/cli/compiler/artifacts/controllers.ts +3 -0
- package/cli/compiler/artifacts/services.ts +14 -8
- package/cli/compiler/common/generatedRouteModules.ts +270 -53
- package/cli/presentation/commands.ts +14 -3
- package/cli/presentation/help.ts +1 -1
- package/cli/runtime/commands.ts +6 -0
- package/cli/scaffold/templates.ts +11 -3
- package/cli/utils/agents.ts +271 -2
- package/cli/verification/changed.ts +460 -0
- package/client/app/index.ts +1 -1
- package/client/services/router/index.tsx +1 -1
- package/common/applicationConfig.ts +177 -0
- package/common/applicationConfigLoader.ts +33 -1
- package/common/dev/contractsDoctor.ts +16 -0
- package/config.ts +5 -1
- package/docs/migration-2.5.md +54 -11
- package/eslint.js +42 -8
- package/package.json +1 -1
- package/tests/agents-utils.test.cjs +72 -0
- package/tests/cli-mcp-command.test.cjs +14 -0
- package/tests/contracts-doctor.test.cjs +98 -0
- package/tests/definition-contracts.test.cjs +129 -0
- package/tests/eslint-rules.test.cjs +100 -0
- package/tests/scaffold-templates.test.cjs +27 -2
- package/tests/verify-changed.test.cjs +200 -0
|
@@ -5,10 +5,13 @@ import * as ts from 'typescript';
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
Application as ApplicationConfig,
|
|
8
|
+
defineVerificationConfig,
|
|
8
9
|
normalizeApplicationIdentityConfig,
|
|
9
10
|
normalizeApplicationSetupConfig,
|
|
11
|
+
normalizeVerificationConfig,
|
|
10
12
|
type TApplicationIdentityConfig,
|
|
11
13
|
type TApplicationSetupConfig,
|
|
14
|
+
type TVerificationConfig,
|
|
12
15
|
} from './applicationConfig';
|
|
13
16
|
import { loadOptionalProteumDotenv } from './env/proteumEnv';
|
|
14
17
|
|
|
@@ -51,7 +54,8 @@ const loadTsModule = (filepath: string): unknown => {
|
|
|
51
54
|
|
|
52
55
|
const requireFromFile = createRequire(normalizedFilepath);
|
|
53
56
|
const runtimeRequire = (specifier: string) => {
|
|
54
|
-
if (specifier === 'proteum/config' || specifier === 'proteum/config.ts')
|
|
57
|
+
if (specifier === 'proteum/config' || specifier === 'proteum/config.ts')
|
|
58
|
+
return { Application: ApplicationConfig, defineVerificationConfig };
|
|
55
59
|
|
|
56
60
|
if (specifier.startsWith('.') || specifier.startsWith('/')) {
|
|
57
61
|
const resolved = resolveLocalModulePath(specifier, normalizedFilepath);
|
|
@@ -81,9 +85,11 @@ const getDefaultExport = <T>(value: unknown): T => {
|
|
|
81
85
|
|
|
82
86
|
export const identityConfigFilename = 'identity.config.ts';
|
|
83
87
|
export const setupConfigFilename = 'proteum.config.ts';
|
|
88
|
+
export const verificationConfigFilename = 'proteum.verify.config.ts';
|
|
84
89
|
|
|
85
90
|
export const resolveIdentityConfigFilepath = (appDir: string) => path.join(appDir, identityConfigFilename);
|
|
86
91
|
export const resolveSetupConfigFilepath = (appDir: string) => path.join(appDir, setupConfigFilename);
|
|
92
|
+
export const resolveVerificationConfigFilepath = (rootDir: string) => path.join(rootDir, verificationConfigFilename);
|
|
87
93
|
|
|
88
94
|
export const loadApplicationIdentityConfig = (appDir: string): TApplicationIdentityConfig => {
|
|
89
95
|
const filepath = resolveIdentityConfigFilepath(appDir);
|
|
@@ -100,3 +106,29 @@ export const loadApplicationSetupConfig = (appDir: string): TApplicationSetupCon
|
|
|
100
106
|
|
|
101
107
|
return normalizeApplicationSetupConfig(getDefaultExport(loadTsModule(filepath)), filepath);
|
|
102
108
|
};
|
|
109
|
+
|
|
110
|
+
export const findVerificationConfigFilepath = (startDir: string) => {
|
|
111
|
+
let currentDir = path.resolve(startDir);
|
|
112
|
+
|
|
113
|
+
while (true) {
|
|
114
|
+
const candidate = resolveVerificationConfigFilepath(currentDir);
|
|
115
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
116
|
+
|
|
117
|
+
const parentDir = path.dirname(currentDir);
|
|
118
|
+
if (parentDir === currentDir) return undefined;
|
|
119
|
+
currentDir = parentDir;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const loadVerificationConfig = (
|
|
124
|
+
startDir: string,
|
|
125
|
+
): { config: TVerificationConfig; filepath?: string; root: string } => {
|
|
126
|
+
const filepath = findVerificationConfigFilepath(startDir);
|
|
127
|
+
if (!filepath) return { config: normalizeVerificationConfig(undefined), root: path.resolve(startDir) };
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
config: normalizeVerificationConfig(getDefaultExport(loadTsModule(filepath)), filepath),
|
|
131
|
+
filepath,
|
|
132
|
+
root: path.dirname(filepath),
|
|
133
|
+
};
|
|
134
|
+
};
|
|
@@ -152,8 +152,24 @@ const isRouterRenderCallback = (node: ts.FunctionLikeDeclaration) => {
|
|
|
152
152
|
return lastFunctionArgument === unwrappedNode;
|
|
153
153
|
};
|
|
154
154
|
|
|
155
|
+
const isDefinitionRouteRenderCallback = (node: ts.FunctionLikeDeclaration) => {
|
|
156
|
+
const unwrappedNode = unwrapExpression(node);
|
|
157
|
+
const parent = unwrappedNode.parent;
|
|
158
|
+
if (!ts.isPropertyAssignment(parent)) return false;
|
|
159
|
+
if (!ts.isIdentifier(parent.name) || parent.name.text !== 'render') return false;
|
|
160
|
+
if (!ts.isObjectLiteralExpression(parent.parent)) return false;
|
|
161
|
+
|
|
162
|
+
const callExpression = parent.parent.parent;
|
|
163
|
+
if (!ts.isCallExpression(callExpression)) return false;
|
|
164
|
+
if (!ts.isIdentifier(callExpression.expression)) return false;
|
|
165
|
+
|
|
166
|
+
const helperName = callExpression.expression.text;
|
|
167
|
+
return helperName === 'definePageRoute' || helperName === 'defineErrorRoute';
|
|
168
|
+
};
|
|
169
|
+
|
|
155
170
|
const isValidHookContainer = (node: ts.FunctionLikeDeclaration) => {
|
|
156
171
|
if (isRouterRenderCallback(node)) return true;
|
|
172
|
+
if (isDefinitionRouteRenderCallback(node)) return true;
|
|
157
173
|
|
|
158
174
|
const functionName = getFunctionName(node);
|
|
159
175
|
if (!functionName) return false;
|
package/config.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
export { Application } from './common/applicationConfig';
|
|
1
|
+
export { Application, defineVerificationConfig } from './common/applicationConfig';
|
|
2
2
|
export type {
|
|
3
3
|
TApplicationIdentityConfig as ApplicationIdentityConfig,
|
|
4
4
|
TApplicationSetupConfig as ApplicationSetupConfig,
|
|
5
|
+
TVerificationCheckScope as VerificationCheckScope,
|
|
6
|
+
TVerificationConfig as VerificationConfig,
|
|
7
|
+
TVerificationRuleConfig as VerificationRuleConfig,
|
|
8
|
+
TVerificationSuiteConfig as VerificationSuiteConfig,
|
|
5
9
|
} from './common/applicationConfig';
|
package/docs/migration-2.5.md
CHANGED
|
@@ -14,7 +14,7 @@ Proteum 2.5 is a breaking cleanup release. It removes contextual app/router magi
|
|
|
14
14
|
|
|
15
15
|
## 1. Install The Published Package
|
|
16
16
|
|
|
17
|
-
Update every Proteum app package in the repo to `proteum@^2.5.
|
|
17
|
+
Update every Proteum app package in the repo to `proteum@^2.5.1`, then reinstall from npm.
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
npm install
|
|
@@ -71,16 +71,39 @@ const createProjectRouter = (app: ProjectApp): ProjectRouter =>
|
|
|
71
71
|
app,
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
const createProjectServices = (app: ProjectApp): ProjectServices => ({
|
|
75
|
+
Billing: new BillingService(app, {}, app),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const ProjectApplication = defineApplication({
|
|
79
|
+
services: createProjectServices,
|
|
78
80
|
router: createProjectRouter,
|
|
79
81
|
});
|
|
80
82
|
|
|
81
83
|
export default ProjectApplication;
|
|
82
84
|
```
|
|
83
85
|
|
|
86
|
+
Generated app globals read the named app type exported by `server/index.ts`. Derive that named type from the default `defineApplication` export when the app does not need a more explicit bootstrap type:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
import type ProjectApplication from '@/server/index';
|
|
90
|
+
|
|
91
|
+
type ProjectApp = InstanceType<typeof ProjectApplication>;
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
In app roots with service constructors that need earlier services during boot, use an explicit exported app type plus a progressively assigned service factory:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
export type ProjectServices = {
|
|
98
|
+
Models: ModelsService;
|
|
99
|
+
Domains: DomainsService;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type ProjectApp = Application & ProjectServices & {
|
|
103
|
+
Router: ProjectRouter;
|
|
104
|
+
};
|
|
105
|
+
```
|
|
106
|
+
|
|
84
107
|
## 4. Migrate Pages
|
|
85
108
|
|
|
86
109
|
Replace legacy page registration with a default-exported page definition.
|
|
@@ -139,7 +162,7 @@ Use `defineServerRoutes((app) => [...])` only when the route definitions need ap
|
|
|
139
162
|
Replace controller classes and `this.input(schema)` with explicit actions.
|
|
140
163
|
|
|
141
164
|
```ts
|
|
142
|
-
import { defineAction, defineController, schema } from '@server/
|
|
165
|
+
import { defineAction, defineController, schema } from '@generated/server/controller';
|
|
143
166
|
|
|
144
167
|
export default defineController({
|
|
145
168
|
path: 'Billing',
|
|
@@ -158,8 +181,27 @@ Rules:
|
|
|
158
181
|
- Read parsed input from `context.input`.
|
|
159
182
|
- Read request state from `request`, `response`, `api`, `auth`, and router-plugin context.
|
|
160
183
|
- Call business logic through `services`, `models`, or `app`.
|
|
184
|
+
- Import controller definition helpers from `@generated/server/controller` when app-specific services, models, router, or request plugins are needed. The generated helper binds context to the concrete app type exported by `server/index.ts`.
|
|
185
|
+
- Keep action schemas aligned with existing typed service methods. If a service method accepts `{ domainIds?: string[]; entries?: Entry[] }`, the `defineAction({ input })` schema must mark those fields optional too. Proteum 2.5 generates strict client types from these schemas, so stale schemas that were previously hidden by loose clients become compile errors.
|
|
186
|
+
|
|
187
|
+
## 7. Migrate Commands
|
|
188
|
+
|
|
189
|
+
Commands should no longer import a default `App` type from `server/index.ts`. The default export is now the application constructor returned by `defineApplication`.
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
import { Commands } from '@server/app/commands';
|
|
193
|
+
import type ProjectApplication from '@/server/index';
|
|
194
|
+
|
|
195
|
+
type ProjectApp = InstanceType<typeof ProjectApplication>;
|
|
196
|
+
|
|
197
|
+
export default class BillingCommands extends Commands<ProjectApp> {
|
|
198
|
+
public async sync() {
|
|
199
|
+
return this.app.Billing.sync();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
```
|
|
161
203
|
|
|
162
|
-
##
|
|
204
|
+
## 8. Remove Legacy Magic
|
|
163
205
|
|
|
164
206
|
Search user source for old contracts and remove every match.
|
|
165
207
|
|
|
@@ -175,7 +217,7 @@ Allowed replacements:
|
|
|
175
217
|
- `this.app`, `this.services`, and `this.models` inside typed services.
|
|
176
218
|
- `defineServerRoutes((app) => [...])` when server route definitions need app services.
|
|
177
219
|
|
|
178
|
-
##
|
|
220
|
+
## 9. Standardize Caught Error Handling
|
|
179
221
|
|
|
180
222
|
Every caught error must end at the same framework error surface. Local UI feedback or protocol responses can still happen, but they are not the terminal error handling step by themselves.
|
|
181
223
|
|
|
@@ -195,7 +237,7 @@ Client rules:
|
|
|
195
237
|
|
|
196
238
|
Do not treat `console.error(error)`, `console.warn(error)`, or any other `console.*(error)` call as error handling. Console calls can be temporary diagnostics, but they must not be the last stop for a caught error.
|
|
197
239
|
|
|
198
|
-
##
|
|
240
|
+
## 10. Refresh Generated Artifacts
|
|
199
241
|
|
|
200
242
|
Do not edit `.proteum/**` manually. Regenerate it from source.
|
|
201
243
|
|
|
@@ -206,7 +248,7 @@ npx proteum typecheck
|
|
|
206
248
|
|
|
207
249
|
If connected local projects are used through `file:` sources, start or validate producer apps before validating the consumer.
|
|
208
250
|
|
|
209
|
-
##
|
|
251
|
+
## 11. Validate Runtime Behavior
|
|
210
252
|
|
|
211
253
|
Run the smallest trustworthy checks first, then broaden when the touched surface requires it.
|
|
212
254
|
|
|
@@ -222,5 +264,6 @@ For protected flows, prefer Proteum session helpers over automating login unless
|
|
|
222
264
|
|
|
223
265
|
- Production route-generation errors where top-level `Router.express(...)` was lifted outside registration are fixed by moving the route into `defineServerRoute({ handler: expressHandler(...) })`.
|
|
224
266
|
- `@app` import errors are fixed by moving runtime access into `data`, `render`, route handlers, controller action handlers, or typed services.
|
|
225
|
-
- Missing `this.app.Router` typings are fixed by exporting the
|
|
267
|
+
- Missing `this.app.Router`, service, or `env` typings are fixed by exporting the project app type from `server/index.ts`, letting `defineApplication({ services, router })` infer from typed factories, and refreshing generated artifacts.
|
|
226
268
|
- Static metadata errors are fixed by moving runtime-dependent values out of `path`, `method`, `options`, and error `code`.
|
|
269
|
+
- Shared package interfaces should match the server controller schemas they wrap. A shared client type that allows `null` or extra enum values while the server schema rejects them will now fail assignment against the generated controller client.
|
package/eslint.js
CHANGED
|
@@ -16,23 +16,24 @@ const createZodTypeFactorySelector = (factoryName) =>
|
|
|
16
16
|
|
|
17
17
|
const skippedTraversalKeys = new Set(['parent', 'loc', 'range', 'tokens', 'comments']);
|
|
18
18
|
|
|
19
|
-
const traverseNode = (node, visit, parent = null, parentKey = null) => {
|
|
19
|
+
const traverseNode = (node, visit, parent = null, parentKey = null, ancestors = []) => {
|
|
20
20
|
if (!node || typeof node !== 'object') return;
|
|
21
21
|
|
|
22
|
-
visit(node, parent, parentKey);
|
|
22
|
+
visit(node, parent, parentKey, ancestors);
|
|
23
23
|
|
|
24
24
|
for (const key of Object.keys(node)) {
|
|
25
25
|
if (skippedTraversalKeys.has(key)) continue;
|
|
26
26
|
|
|
27
27
|
const value = node[key];
|
|
28
|
+
const nextAncestors = [...ancestors, { node, childKey: key }];
|
|
28
29
|
if (Array.isArray(value)) {
|
|
29
30
|
value.forEach((child) => {
|
|
30
|
-
if (child && typeof child.type === 'string') traverseNode(child, visit, node, key);
|
|
31
|
+
if (child && typeof child.type === 'string') traverseNode(child, visit, node, key, nextAncestors);
|
|
31
32
|
});
|
|
32
33
|
continue;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
if (value && typeof value.type === 'string') traverseNode(value, visit, node, key);
|
|
36
|
+
if (value && typeof value.type === 'string') traverseNode(value, visit, node, key, nextAncestors);
|
|
36
37
|
}
|
|
37
38
|
};
|
|
38
39
|
|
|
@@ -160,8 +161,35 @@ const isServerErrorReporterCall = (callExpression) =>
|
|
|
160
161
|
|
|
161
162
|
const isPromiseRejectCall = (callExpression) => getCalleePropertyName(callExpression.callee) === 'reject';
|
|
162
163
|
|
|
163
|
-
const
|
|
164
|
+
const hasOptionalCallBoundary = (callExpression, ancestors = []) => {
|
|
165
|
+
if (callExpression.optional === true) return true;
|
|
166
|
+
|
|
167
|
+
let hasOptional = false;
|
|
168
|
+
traverseNode(callExpression.callee, (child) => {
|
|
169
|
+
if (child.type === 'ChainExpression' || child.optional === true) hasOptional = true;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return hasOptional || ancestors.some(({ node }) => node.type === 'ChainExpression');
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const isUnderConditionalControlFlow = (ancestors = []) =>
|
|
176
|
+
ancestors.some(({ node, childKey }) => {
|
|
177
|
+
if (node.type === 'IfStatement') return childKey === 'consequent' || childKey === 'alternate';
|
|
178
|
+
if (node.type === 'ConditionalExpression') return childKey === 'consequent' || childKey === 'alternate';
|
|
179
|
+
if (node.type === 'LogicalExpression') return childKey === 'right';
|
|
180
|
+
if (node.type === 'SwitchCase') return childKey === 'consequent';
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
['ForInStatement', 'ForOfStatement', 'ForStatement', 'WhileStatement', 'DoWhileStatement'].includes(
|
|
184
|
+
node.type,
|
|
185
|
+
) && childKey === 'body'
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const isPreservingCall = (callExpression, names, side, ancestors = []) => {
|
|
164
190
|
if (!nodeReferencesName(callExpression, names)) return false;
|
|
191
|
+
if (hasOptionalCallBoundary(callExpression, ancestors)) return false;
|
|
192
|
+
if (isUnderConditionalControlFlow(ancestors)) return false;
|
|
165
193
|
if (isConsoleMember(callExpression.callee)) return false;
|
|
166
194
|
if (isPromiseRejectCall(callExpression)) return true;
|
|
167
195
|
if (side === 'client') return isClientErrorHandlerCall(callExpression);
|
|
@@ -173,9 +201,15 @@ const isPreservingCall = (callExpression, names, side) => {
|
|
|
173
201
|
const handlerPreservesCaughtError = (node, names, side) => {
|
|
174
202
|
let preserves = false;
|
|
175
203
|
|
|
176
|
-
traverseNode(node, (child) => {
|
|
177
|
-
if (
|
|
178
|
-
|
|
204
|
+
traverseNode(node, (child, _parent, _parentKey, ancestors) => {
|
|
205
|
+
if (
|
|
206
|
+
child.type === 'ThrowStatement' &&
|
|
207
|
+
nodeReferencesName(child.argument, names) &&
|
|
208
|
+
!isUnderConditionalControlFlow(ancestors)
|
|
209
|
+
) {
|
|
210
|
+
preserves = true;
|
|
211
|
+
}
|
|
212
|
+
if (child.type === 'CallExpression' && isPreservingCall(child, names, side, ancestors)) preserves = true;
|
|
179
213
|
});
|
|
180
214
|
|
|
181
215
|
return preserves;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proteum",
|
|
3
3
|
"description": "LLM-first Opinionated Typescript Framework for web applications.",
|
|
4
|
-
"version": "2.5.
|
|
4
|
+
"version": "2.5.3",
|
|
5
5
|
"author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
|
|
6
6
|
"repository": "git://github.com/gaetanlegac/proteum.git",
|
|
7
7
|
"license": "MIT",
|
|
@@ -15,6 +15,23 @@ const writeFile = (filepath, content) => {
|
|
|
15
15
|
fs.writeFileSync(filepath, content);
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
const assertClaudeSymlink = (root, relativeDir = '') => {
|
|
19
|
+
const linkPath = path.join(root, relativeDir, 'CLAUDE.md');
|
|
20
|
+
const stats = fs.lstatSync(linkPath);
|
|
21
|
+
|
|
22
|
+
assert.equal(stats.isSymbolicLink(), true, `${linkPath} should be a symlink`);
|
|
23
|
+
assert.equal(fs.readlinkSync(linkPath), 'AGENTS.md');
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const pathEntryExists = (filepath) => {
|
|
27
|
+
try {
|
|
28
|
+
fs.lstatSync(filepath);
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
18
35
|
const makeTempRoot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-agents-'));
|
|
19
36
|
|
|
20
37
|
const createCoreFixture = () => {
|
|
@@ -53,6 +70,7 @@ const createAppFixture = () => {
|
|
|
53
70
|
'node_modules',
|
|
54
71
|
'# Proteum-managed instruction files',
|
|
55
72
|
'/AGENTS.md',
|
|
73
|
+
'/CLAUDE.md',
|
|
56
74
|
'/CODING_STYLE.md',
|
|
57
75
|
'/DOCUMENTATION.md',
|
|
58
76
|
'# End Proteum-managed instruction files',
|
|
@@ -137,9 +155,17 @@ test('standalone configure creates tracked instruction files with routing contra
|
|
|
137
155
|
assert.equal(fs.existsSync(path.join(appRoot, 'tests', 'e2e', 'REAL_WORLD_JOURNEY_TESTS.md')), true);
|
|
138
156
|
assert.match(fs.readFileSync(path.join(appRoot, 'tests', 'e2e', 'REAL_WORLD_JOURNEY_TESTS.md'), 'utf8'), /Journey rule/);
|
|
139
157
|
assert.doesNotMatch(fs.readFileSync(path.join(appRoot, 'tests', 'e2e', 'REAL_WORLD_JOURNEY_TESTS.md'), 'utf8'), /## Source: CODING_STYLE\.md/);
|
|
158
|
+
assertClaudeSymlink(appRoot);
|
|
159
|
+
assertClaudeSymlink(appRoot, 'client');
|
|
160
|
+
assertClaudeSymlink(appRoot, 'client/pages');
|
|
161
|
+
assertClaudeSymlink(appRoot, 'server/routes');
|
|
162
|
+
assertClaudeSymlink(appRoot, 'server/services');
|
|
163
|
+
assertClaudeSymlink(appRoot, 'tests');
|
|
164
|
+
assertClaudeSymlink(appRoot, 'tests/e2e');
|
|
140
165
|
assert.doesNotMatch(agentsContent, /Before reading or applying instructions from this file/);
|
|
141
166
|
assert.doesNotMatch(gitignoreContent, /Proteum-managed instruction files/);
|
|
142
167
|
assert.doesNotMatch(gitignoreContent, /^\/AGENTS\.md$/m);
|
|
168
|
+
assert.doesNotMatch(gitignoreContent, /^\/CLAUDE\.md$/m);
|
|
143
169
|
assert.doesNotMatch(gitignoreContent, /^\/DOCUMENTATION\.md$/m);
|
|
144
170
|
});
|
|
145
171
|
|
|
@@ -255,13 +281,20 @@ test('monorepo configure writes root and app instruction files', () => {
|
|
|
255
281
|
assert.match(fs.readFileSync(path.join(monorepoRoot, 'tests', 'e2e', 'AGENTS.md'), 'utf8'), /## Source: tests\/e2e\/AGENTS\.md/);
|
|
256
282
|
assert.match(fs.readFileSync(path.join(monorepoRoot, 'tests', 'e2e', 'REAL_WORLD_JOURNEY_TESTS.md'), 'utf8'), /## Source: tests\/e2e\/REAL_WORLD_JOURNEY_TESTS\.md/);
|
|
257
283
|
assert.doesNotMatch(fs.readFileSync(path.join(monorepoRoot, 'tests', 'e2e', 'REAL_WORLD_JOURNEY_TESTS.md'), 'utf8'), /## Source: CODING_STYLE\.md/);
|
|
284
|
+
assertClaudeSymlink(monorepoRoot);
|
|
285
|
+
assertClaudeSymlink(monorepoRoot, 'tests');
|
|
286
|
+
assertClaudeSymlink(monorepoRoot, 'tests/e2e');
|
|
258
287
|
assert.equal(fs.existsSync(path.join(appRoot, 'tests', 'AGENTS.md')), false);
|
|
259
288
|
assert.equal(fs.existsSync(path.join(appRoot, 'tests', 'e2e', 'AGENTS.md')), false);
|
|
289
|
+
assert.equal(pathEntryExists(path.join(appRoot, 'tests', 'CLAUDE.md')), false);
|
|
290
|
+
assert.equal(pathEntryExists(path.join(appRoot, 'tests', 'e2e', 'CLAUDE.md')), false);
|
|
260
291
|
const appAgentsContent = fs.readFileSync(path.join(appRoot, 'AGENTS.md'), 'utf8');
|
|
261
292
|
assert.match(appAgentsContent, /## Agent Routing Contract/);
|
|
262
293
|
assert.doesNotMatch(appAgentsContent, /## Known Proteum Apps/);
|
|
263
294
|
assert.doesNotMatch(appAgentsContent, /Do not start `npx proteum dev` from this root/);
|
|
264
295
|
assert.match(fs.readFileSync(path.join(appRoot, 'client', 'AGENTS.md'), 'utf8'), /## Source: client\/AGENTS\.md/);
|
|
296
|
+
assertClaudeSymlink(appRoot);
|
|
297
|
+
assertClaudeSymlink(appRoot, 'client');
|
|
265
298
|
assert.equal(fs.existsSync(path.join(appRoot, 'CODING_STYLE.md')), false);
|
|
266
299
|
assert.equal(fs.existsSync(path.join(appRoot, 'DOCUMENTATION.md')), false);
|
|
267
300
|
assert.equal(fs.existsSync(path.join(appRoot, 'diagnostics.md')), false);
|
|
@@ -331,6 +364,45 @@ test('configure migrates legacy managed symlinks to tracked files', () => {
|
|
|
331
364
|
assert.equal(result.updated.some((entry) => entry.endsWith('/AGENTS.md')), true);
|
|
332
365
|
assert.equal(stats.isSymbolicLink(), false);
|
|
333
366
|
assert.match(content, /# Proteum Instructions/);
|
|
367
|
+
assertClaudeSymlink(appRoot);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('configure migrates one-line Claude pointer files to symlinks', () => {
|
|
371
|
+
const coreRoot = createCoreFixture();
|
|
372
|
+
const appRoot = createAppFixture();
|
|
373
|
+
const linkPath = path.join(appRoot, 'CLAUDE.md');
|
|
374
|
+
|
|
375
|
+
writeFile(linkPath, '@AGENTS.md\n');
|
|
376
|
+
|
|
377
|
+
const result = configureProjectAgentInstructions({ appRoot, coreRoot });
|
|
378
|
+
|
|
379
|
+
assert.equal(result.updated.some((entry) => entry.endsWith('/CLAUDE.md')), true);
|
|
380
|
+
assertClaudeSymlink(appRoot);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('configure reports blocked Claude companion paths unless overwrite is allowed', () => {
|
|
384
|
+
const coreRoot = createCoreFixture();
|
|
385
|
+
const appRoot = createAppFixture();
|
|
386
|
+
const blockedPath = path.join(appRoot, 'CLAUDE.md');
|
|
387
|
+
|
|
388
|
+
writeFile(blockedPath, '# Local Claude Notes\n\n- Keep this local rule.\n');
|
|
389
|
+
|
|
390
|
+
const preview = configureProjectAgentInstructions({ appRoot, coreRoot, dryRun: true });
|
|
391
|
+
assert.equal(preview.blocked.some((entry) => entry.endsWith('/CLAUDE.md')), true);
|
|
392
|
+
|
|
393
|
+
const blockedResult = configureProjectAgentInstructions({ appRoot, coreRoot });
|
|
394
|
+
assert.equal(blockedResult.blocked.some((entry) => entry.endsWith('/CLAUDE.md')), true);
|
|
395
|
+
assert.equal(fs.lstatSync(blockedPath).isFile(), true);
|
|
396
|
+
assert.match(fs.readFileSync(blockedPath, 'utf8'), /Keep this local rule/);
|
|
397
|
+
|
|
398
|
+
const result = configureProjectAgentInstructions({
|
|
399
|
+
appRoot,
|
|
400
|
+
coreRoot,
|
|
401
|
+
overwriteBlockedPaths: [blockedPath],
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
assert.equal(result.overwritten.some((entry) => entry.endsWith('/CLAUDE.md')), true);
|
|
405
|
+
assertClaudeSymlink(appRoot);
|
|
334
406
|
});
|
|
335
407
|
|
|
336
408
|
test('configure reports blocked paths unless overwrite is allowed', () => {
|
|
@@ -205,6 +205,20 @@ test('mcp help describes projectId routing', () => {
|
|
|
205
205
|
assert.match(output, /--stdio/);
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
+
test('verify help documents changed-file verification flags', () => {
|
|
209
|
+
const result = spawnSync(process.execPath, [cliBin, 'verify', '--help'], {
|
|
210
|
+
cwd: coreRoot,
|
|
211
|
+
encoding: 'utf8',
|
|
212
|
+
});
|
|
213
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
214
|
+
|
|
215
|
+
assert.equal(result.status, 0);
|
|
216
|
+
assert.match(output, /changed/);
|
|
217
|
+
assert.match(output, /--staged/);
|
|
218
|
+
assert.match(output, /--base/);
|
|
219
|
+
assert.match(output, /--dry-run/);
|
|
220
|
+
});
|
|
221
|
+
|
|
208
222
|
test('mcp daemon launch prints a central MCP connection banner', async () => {
|
|
209
223
|
const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-mcp-daemon-launch-'));
|
|
210
224
|
const reserveServer = http.createServer((req, res) => res.end('reserved'));
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const coreRoot = path.resolve(__dirname, '..');
|
|
7
|
+
process.env.TS_NODE_PROJECT = path.join(coreRoot, 'tsconfig.json');
|
|
8
|
+
process.env.TS_NODE_TRANSPILE_ONLY = '1';
|
|
9
|
+
require('ts-node/register/transpile-only');
|
|
10
|
+
|
|
11
|
+
const { buildContractsDoctorResponse } = require('../common/dev/contractsDoctor.ts');
|
|
12
|
+
|
|
13
|
+
const touch = (filepath, content = '') => {
|
|
14
|
+
fs.mkdirSync(path.dirname(filepath), { recursive: true });
|
|
15
|
+
fs.writeFileSync(filepath, content);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const createManifest = (appRoot, pageFilepath) => ({
|
|
19
|
+
app: {
|
|
20
|
+
identityFilepath: path.join(appRoot, 'identity.config.ts'),
|
|
21
|
+
root: appRoot,
|
|
22
|
+
setupFilepath: path.join(appRoot, 'proteum.config.ts'),
|
|
23
|
+
},
|
|
24
|
+
connectedProjects: [],
|
|
25
|
+
commands: [],
|
|
26
|
+
controllers: [],
|
|
27
|
+
layouts: [],
|
|
28
|
+
routes: {
|
|
29
|
+
client: [
|
|
30
|
+
{
|
|
31
|
+
chunkFilepath: 'landing/index',
|
|
32
|
+
filepath: pageFilepath,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
server: [],
|
|
36
|
+
},
|
|
37
|
+
services: {
|
|
38
|
+
app: [],
|
|
39
|
+
routerPlugins: [],
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('contracts doctor allows hooks inside definePageRoute render callbacks', () => {
|
|
44
|
+
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-contracts-doctor-'));
|
|
45
|
+
const pageFilepath = path.join(appRoot, 'client/pages/landing/index.tsx');
|
|
46
|
+
|
|
47
|
+
touch(path.join(appRoot, 'proteum.config.ts'), 'export default {};');
|
|
48
|
+
touch(path.join(appRoot, 'identity.config.ts'), 'export default {};');
|
|
49
|
+
touch(path.join(appRoot, 'client/hooks/useToast.ts'), 'export default function useToast() { return () => undefined; }');
|
|
50
|
+
touch(
|
|
51
|
+
pageFilepath,
|
|
52
|
+
`import { definePageRoute } from '@common/router/definitions';
|
|
53
|
+
import useToast from '@/client/hooks/useToast';
|
|
54
|
+
|
|
55
|
+
export default definePageRoute({
|
|
56
|
+
path: '/',
|
|
57
|
+
options: {},
|
|
58
|
+
data: null,
|
|
59
|
+
render: () => {
|
|
60
|
+
const toast = useToast();
|
|
61
|
+
return toast ? null : null;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
`,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
for (const filepath of [
|
|
68
|
+
'proteum.connected.json',
|
|
69
|
+
'.proteum/manifest.json',
|
|
70
|
+
'.proteum/proteum.connected.d.ts',
|
|
71
|
+
'.proteum/client/context.ts',
|
|
72
|
+
'.proteum/client/controllers.ts',
|
|
73
|
+
'.proteum/client/layouts.ts',
|
|
74
|
+
'.proteum/client/models.ts',
|
|
75
|
+
'.proteum/client/routes.ts',
|
|
76
|
+
'.proteum/client/services.d.ts',
|
|
77
|
+
'.proteum/common/controllers.ts',
|
|
78
|
+
'.proteum/common/models.ts',
|
|
79
|
+
'.proteum/common/services.d.ts',
|
|
80
|
+
'.proteum/server/commands.app.d.ts',
|
|
81
|
+
'.proteum/server/commands.d.ts',
|
|
82
|
+
'.proteum/server/commands.ts',
|
|
83
|
+
'.proteum/server/controllers.ts',
|
|
84
|
+
'.proteum/server/models.ts',
|
|
85
|
+
'.proteum/server/routes.ts',
|
|
86
|
+
'.proteum/server/services.d.ts',
|
|
87
|
+
'.proteum/client/route-modules/landing/index.tsx',
|
|
88
|
+
'.proteum/server/route-modules/client/pages/landing/index.tsx',
|
|
89
|
+
]) {
|
|
90
|
+
touch(path.join(appRoot, filepath));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const response = buildContractsDoctorResponse(createManifest(appRoot, pageFilepath));
|
|
94
|
+
assert.equal(
|
|
95
|
+
response.diagnostics.some((diagnostic) => diagnostic.code === 'runtime/provider-hook-outside-provider'),
|
|
96
|
+
false,
|
|
97
|
+
);
|
|
98
|
+
});
|