@mandujs/core 0.54.1 → 0.54.2
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 +1 -1
- package/src/a11y/run-audit.ts +15 -15
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/build.ts +26 -19
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -9
- package/src/bundler/plugins/block-generated-imports.ts +13 -12
- package/src/config/validate.ts +1 -1
- package/src/deploy/inference/context.ts +82 -15
- package/src/filling/context.ts +17 -4
- package/src/guard/check.ts +9 -9
- package/src/kitchen/api/file-api.ts +11 -8
- package/src/resource/__tests__/schema.test.ts +14 -9
- package/src/resource/generators/slot.ts +72 -71
- package/src/resource/schema.ts +21 -13
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -0
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -0
- package/src/runtime/__tests__/page-render-response.test.ts +54 -0
- package/src/runtime/__tests__/request-middleware.test.ts +70 -0
- package/src/runtime/devtools-adapter.ts +68 -0
- package/src/runtime/escape.ts +34 -6
- package/src/runtime/observability-lifecycle.ts +290 -0
- package/src/runtime/page-render-response.ts +110 -0
- package/src/runtime/request-middleware.ts +31 -0
- package/src/runtime/server.ts +228 -944
- package/src/runtime/ssr.ts +20 -7
- package/src/runtime/static-files.ts +289 -0
package/package.json
CHANGED
package/src/a11y/run-audit.ts
CHANGED
|
@@ -69,8 +69,11 @@ interface DomProvider {
|
|
|
69
69
|
fromHtml(html: string, url: string): Promise<{ window: unknown; dispose: () => Promise<void> }>;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
const DEFAULT_MAX_FILES = 500;
|
|
73
|
-
const DEFAULT_MIN_IMPACT: AuditImpact = "minor";
|
|
72
|
+
const DEFAULT_MAX_FILES = 500;
|
|
73
|
+
const DEFAULT_MIN_IMPACT: AuditImpact = "minor";
|
|
74
|
+
const AXE_CORE_MODULE = "axe-core";
|
|
75
|
+
const JSDOM_MODULE = "jsdom";
|
|
76
|
+
const HAPPY_DOM_MODULE = "happy-dom";
|
|
74
77
|
|
|
75
78
|
/**
|
|
76
79
|
* Zero every entry in an impact-count record. Returned by value so
|
|
@@ -100,11 +103,10 @@ async function resolveAxe(options: RunAuditOptions): Promise<AxeLike | null> {
|
|
|
100
103
|
return null;
|
|
101
104
|
}
|
|
102
105
|
};
|
|
103
|
-
|
|
104
|
-
if (options.axeLoader) return tryLoad(options.axeLoader);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
106
|
+
|
|
107
|
+
if (options.axeLoader) return tryLoad(options.axeLoader);
|
|
108
|
+
return tryLoad(() => import(AXE_CORE_MODULE));
|
|
109
|
+
}
|
|
108
110
|
|
|
109
111
|
/**
|
|
110
112
|
* Resolve a DOM provider. Prefers jsdom; falls back to HappyDOM via
|
|
@@ -126,10 +128,9 @@ async function resolveDomProvider(options: RunAuditOptions): Promise<DomProvider
|
|
|
126
128
|
return null;
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
// Preferred path — jsdom.
|
|
130
|
-
try {
|
|
131
|
-
|
|
132
|
-
const jsdom = await import("jsdom");
|
|
131
|
+
// Preferred path — jsdom.
|
|
132
|
+
try {
|
|
133
|
+
const jsdom = await import(JSDOM_MODULE);
|
|
133
134
|
const JSDOMCtor = (jsdom as { JSDOM?: new (html: string, opts?: unknown) => unknown }).JSDOM;
|
|
134
135
|
if (JSDOMCtor) {
|
|
135
136
|
return {
|
|
@@ -153,10 +154,9 @@ async function resolveDomProvider(options: RunAuditOptions): Promise<DomProvider
|
|
|
153
154
|
// jsdom not installed — fall through to HappyDOM.
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
// Fallback path — HappyDOM.
|
|
157
|
-
try {
|
|
158
|
-
|
|
159
|
-
const happy = await import("happy-dom");
|
|
157
|
+
// Fallback path — HappyDOM.
|
|
158
|
+
try {
|
|
159
|
+
const happy = await import(HAPPY_DOM_MODULE);
|
|
160
160
|
const WindowCtor = (happy as { Window?: new (opts?: { url?: string; innerWidth?: number }) => unknown }).Window;
|
|
161
161
|
if (WindowCtor) {
|
|
162
162
|
return {
|
|
@@ -206,13 +206,13 @@ export function generateTemplatePatches(
|
|
|
206
206
|
"Do NOT import or re-export the island in page.tsx — island() returns " +
|
|
207
207
|
"a config object, not a React component. Use data-island attributes instead.",
|
|
208
208
|
type: "modify",
|
|
209
|
-
content:
|
|
210
|
-
`// Example: app/my-feature.island.tsx\n` +
|
|
211
|
-
`import {
|
|
212
|
-
`export default
|
|
213
|
-
`// In page.tsx, reference via: <div data-island="my-feature">...</div>`,
|
|
214
|
-
confidence: 0.9,
|
|
215
|
-
});
|
|
209
|
+
content:
|
|
210
|
+
`// Example: app/my-feature.island.tsx\n` +
|
|
211
|
+
`import { wrapComponent } from "@mandujs/core/client";\n\n` +
|
|
212
|
+
`export default wrapComponent(MyComponent);\n\n` +
|
|
213
|
+
`// In page.tsx, reference via: <div data-island="my-feature">...</div>`,
|
|
214
|
+
confidence: 0.9,
|
|
215
|
+
});
|
|
216
216
|
break;
|
|
217
217
|
|
|
218
218
|
default:
|
package/src/bundler/build.ts
CHANGED
|
@@ -1607,9 +1607,9 @@ async function buildVendorShims(
|
|
|
1607
1607
|
}
|
|
1608
1608
|
}
|
|
1609
1609
|
|
|
1610
|
-
const buildShim = async (
|
|
1611
|
-
shim: { name: string; source: string; key: VendorShimKey; cacheId: string }
|
|
1612
|
-
): Promise<{ key: VendorShimKey; cacheId: string; outputName?: string; outputPath?: string; error?: string }> => {
|
|
1610
|
+
const buildShim = async (
|
|
1611
|
+
shim: { name: string; source: string; key: VendorShimKey; cacheId: string }
|
|
1612
|
+
): Promise<{ key: VendorShimKey; cacheId: string; outputName?: string; outputPath?: string; error?: string }> => {
|
|
1613
1613
|
const srcPath = path.join(outDir, `${shim.name}.src.js`);
|
|
1614
1614
|
const outputName = `${shim.name}.js`;
|
|
1615
1615
|
|
|
@@ -1645,14 +1645,14 @@ async function buildVendorShims(
|
|
|
1645
1645
|
|
|
1646
1646
|
await fs.unlink(srcPath).catch(() => {});
|
|
1647
1647
|
|
|
1648
|
-
if (!result.success) {
|
|
1649
|
-
const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
|
|
1650
|
-
return {
|
|
1651
|
-
key: shim.key,
|
|
1652
|
-
cacheId: shim.cacheId,
|
|
1653
|
-
error: `Vendor shim '${shim.name}' build failed (source: ${srcPath}):\n${grouped}\n
|
|
1654
|
-
};
|
|
1655
|
-
}
|
|
1648
|
+
if (!result.success) {
|
|
1649
|
+
const grouped = result.logs.map((l) => ` - ${l.message}`).join("\n");
|
|
1650
|
+
return {
|
|
1651
|
+
key: shim.key,
|
|
1652
|
+
cacheId: shim.cacheId,
|
|
1653
|
+
error: `Vendor shim '${shim.name}' build failed (source: ${srcPath}):\n${grouped}\n ${vendorShimFailureHint(shim.name)}`,
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
1656
|
|
|
1657
1657
|
return {
|
|
1658
1658
|
key: shim.key,
|
|
@@ -1662,13 +1662,13 @@ async function buildVendorShims(
|
|
|
1662
1662
|
};
|
|
1663
1663
|
} catch (error) {
|
|
1664
1664
|
await fs.unlink(srcPath).catch(() => {});
|
|
1665
|
-
return {
|
|
1666
|
-
key: shim.key,
|
|
1667
|
-
cacheId: shim.cacheId,
|
|
1668
|
-
error: `[${shim.name}] ${String(error)}`,
|
|
1669
|
-
};
|
|
1670
|
-
}
|
|
1671
|
-
};
|
|
1665
|
+
return {
|
|
1666
|
+
key: shim.key,
|
|
1667
|
+
cacheId: shim.cacheId,
|
|
1668
|
+
error: `[${shim.name}] ${String(error)}\n ${vendorShimFailureHint(shim.name)}`,
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
1672
|
|
|
1673
1673
|
const buildResults = await Promise.all(shims.map((shim) => buildShim(shim)));
|
|
1674
1674
|
const writeEntries: VendorCacheWriteEntry[] = [];
|
|
@@ -1711,7 +1711,14 @@ async function buildVendorShims(
|
|
|
1711
1711
|
fastRefreshRuntime: results.fastRefreshRuntime,
|
|
1712
1712
|
errors,
|
|
1713
1713
|
};
|
|
1714
|
-
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
function vendorShimFailureHint(shimName: string): string {
|
|
1717
|
+
if (shimName.includes("react-refresh")) {
|
|
1718
|
+
return "Hint: install the optional dev peer dependency with `bun add -d react-refresh`.";
|
|
1719
|
+
}
|
|
1720
|
+
return "Hint: check the import paths and ensure the vendor package is installed.";
|
|
1721
|
+
}
|
|
1715
1722
|
|
|
1716
1723
|
/**
|
|
1717
1724
|
* 단일 Island 번들 빌드
|
|
@@ -80,15 +80,19 @@ describe("defaultAllowImporter", () => {
|
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
describe("DEFAULT_BLOCK_FILTER", () => {
|
|
83
|
-
test("matches __generated__ specifiers", () => {
|
|
84
|
-
expect("./__generated__/foo").toMatch(DEFAULT_BLOCK_FILTER);
|
|
85
|
-
expect("../../src/__generated__/routes").toMatch(DEFAULT_BLOCK_FILTER);
|
|
86
|
-
});
|
|
87
|
-
test("
|
|
88
|
-
expect("
|
|
89
|
-
expect("
|
|
90
|
-
});
|
|
91
|
-
|
|
83
|
+
test("matches __generated__ specifiers", () => {
|
|
84
|
+
expect("./__generated__/foo").toMatch(DEFAULT_BLOCK_FILTER);
|
|
85
|
+
expect("../../src/__generated__/routes").toMatch(DEFAULT_BLOCK_FILTER);
|
|
86
|
+
});
|
|
87
|
+
test("matches direct .mandu/generated relative specifiers", () => {
|
|
88
|
+
expect("../.mandu/generated/routes").toMatch(DEFAULT_BLOCK_FILTER);
|
|
89
|
+
expect("../../../.mandu/generated/server/repos/party.repo").toMatch(DEFAULT_BLOCK_FILTER);
|
|
90
|
+
});
|
|
91
|
+
test("does NOT match look-alikes without double underscores", () => {
|
|
92
|
+
expect("./generated/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
|
|
93
|
+
expect("./src/generate/foo".match(DEFAULT_BLOCK_FILTER)).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
92
96
|
|
|
93
97
|
describe("defaultBundlerPlugins", () => {
|
|
94
98
|
test("installs block-generated-imports by default", () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bun bundler plugin — hard-fail on direct
|
|
2
|
+
* Bun bundler plugin — hard-fail on direct generated-artifact imports.
|
|
3
3
|
*
|
|
4
4
|
* Background
|
|
5
5
|
* ──────────
|
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
* but it only runs when the user (or CI) invokes `mandu guard check`.
|
|
9
9
|
* Autonomous coding agents routinely bypass that step. This plugin closes
|
|
10
10
|
* the gap at the bundler level: every `mandu dev` / `mandu build` pass
|
|
11
|
-
* installs it by default, and any import whose specifier
|
|
12
|
-
* `__generated__` fails the build with a structured,
|
|
11
|
+
* installs it by default, and any import whose specifier targets
|
|
12
|
+
* `__generated__` or `.mandu/generated` fails the build with a structured,
|
|
13
|
+
* actionable error.
|
|
13
14
|
*
|
|
14
15
|
* Design
|
|
15
16
|
* ──────
|
|
16
|
-
* - `onResolve({ filter:
|
|
17
|
-
* whose *specifier* matches the regex, along with the importer's
|
|
18
|
-
* (`args.importer`). We never return a result; we always throw.
|
|
17
|
+
* - `onResolve({ filter: DEFAULT_BLOCK_FILTER })` — Bun hands us every
|
|
18
|
+
* import whose *specifier* matches the regex, along with the importer's
|
|
19
|
+
* path (`args.importer`). We never return a result; we always throw.
|
|
19
20
|
* - The error is `ForbiddenGeneratedImportError`, a named subclass of
|
|
20
21
|
* `Error`. Tests can `instanceof`-check; Bun surfaces `error.message` in
|
|
21
22
|
* its `result.logs` output for CLI display.
|
|
@@ -90,8 +91,8 @@ export interface BlockGeneratedImportsOptions {
|
|
|
90
91
|
allowImporter?: (importerPath: string) => boolean;
|
|
91
92
|
/**
|
|
92
93
|
* Custom filter regex applied to the import specifier. Defaults to
|
|
93
|
-
*
|
|
94
|
-
* test harnesses that want to narrow or broaden the filter.
|
|
94
|
+
* generated-artifact paths. Mandu ships a single default — exposing this
|
|
95
|
+
* for test harnesses that want to narrow or broaden the filter.
|
|
95
96
|
*/
|
|
96
97
|
filter?: RegExp;
|
|
97
98
|
}
|
|
@@ -114,7 +115,7 @@ export function defaultAllowImporter(importerPath: string): boolean {
|
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
/**
|
|
117
|
-
* Build a `BunPlugin` that blocks direct
|
|
118
|
+
* Build a `BunPlugin` that blocks direct generated-artifact imports.
|
|
118
119
|
*
|
|
119
120
|
* Usage — call from `defaultBundlerPlugins(config)` (see `./index.ts`).
|
|
120
121
|
* Every `safeBuild` / `Bun.build` invocation in Mandu funnels through
|
|
@@ -123,7 +124,7 @@ export function defaultAllowImporter(importerPath: string): boolean {
|
|
|
123
124
|
export function blockGeneratedImports(
|
|
124
125
|
options: BlockGeneratedImportsOptions = {},
|
|
125
126
|
): BunPlugin {
|
|
126
|
-
const filter = options.filter ??
|
|
127
|
+
const filter = options.filter ?? DEFAULT_BLOCK_FILTER;
|
|
127
128
|
const allowImporter = options.allowImporter ?? defaultAllowImporter;
|
|
128
129
|
|
|
129
130
|
return {
|
|
@@ -151,5 +152,5 @@ export function blockGeneratedImports(
|
|
|
151
152
|
};
|
|
152
153
|
}
|
|
153
154
|
|
|
154
|
-
/** Exported for unit-test convenience — keep the filter text assertable. */
|
|
155
|
-
export const DEFAULT_BLOCK_FILTER = /__generated__/;
|
|
155
|
+
/** Exported for unit-test convenience — keep the filter text assertable. */
|
|
156
|
+
export const DEFAULT_BLOCK_FILTER = /__generated__|(?:^|[\/\\])\.mandu[\/\\]generated(?:[\/\\]|$)/;
|
package/src/config/validate.ts
CHANGED
|
@@ -43,7 +43,7 @@ function _strictWithWarnings<T extends z.ZodRawShape>(
|
|
|
43
43
|
*/
|
|
44
44
|
const ServerConfigSchema = z
|
|
45
45
|
.object({
|
|
46
|
-
port: z.number().min(1).max(65535).default(
|
|
46
|
+
port: z.number().min(1).max(65535).default(3333),
|
|
47
47
|
// Default `"::"` (IPv6 wildcard, dual-stack): accepts both IPv4 and
|
|
48
48
|
// IPv6 clients on one socket. Fixes Windows Node 17+ fetch failing
|
|
49
49
|
// with `ECONNREFUSED ::1:PORT` because `localhost` resolves to `::1`
|
|
@@ -78,8 +78,8 @@ export async function buildDeployInferenceContext(
|
|
|
78
78
|
// still gets the manifest metadata and falls back to defaults.
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
const imports = extractImports(source);
|
|
82
|
-
const dependencyClasses =
|
|
81
|
+
const imports = extractImports(source);
|
|
82
|
+
const dependencyClasses = classifySourceDependencies(source, imports);
|
|
83
83
|
// Mandu's manifest patterns use `:param` / `*` (path-pattern style),
|
|
84
84
|
// not bracket-form. Detect both shapes so the heuristic doesn't
|
|
85
85
|
// misclassify dynamic routes as prerenderable. Examples:
|
|
@@ -139,18 +139,82 @@ export function extractImports(source: string): string[] {
|
|
|
139
139
|
}
|
|
140
140
|
|
|
141
141
|
/** Map import specifiers to coarse dependency classes. */
|
|
142
|
-
export function classifyImports(imports: string[]): ReadonlySet<DependencyClass> {
|
|
143
|
-
const classes = new Set<DependencyClass>();
|
|
144
|
-
for (const spec of imports) {
|
|
142
|
+
export function classifyImports(imports: string[]): ReadonlySet<DependencyClass> {
|
|
143
|
+
const classes = new Set<DependencyClass>();
|
|
144
|
+
for (const spec of imports) {
|
|
145
145
|
const cls = classifyOne(spec);
|
|
146
146
|
if (cls) classes.add(cls);
|
|
147
147
|
}
|
|
148
148
|
if (classes.size === 0) classes.add("fetch-only");
|
|
149
|
-
return classes;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
149
|
+
return classes;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Classify dependency signals that do not always appear as bare imports.
|
|
154
|
+
*
|
|
155
|
+
* Dogfooding surfaced Mandu API routes importing project-local
|
|
156
|
+
* `src/server/infra/db` helpers and using `db` tagged templates. Those
|
|
157
|
+
* are server-only even when the bare imports look edge-safe.
|
|
158
|
+
*/
|
|
159
|
+
export function classifySourceDependencies(
|
|
160
|
+
source: string,
|
|
161
|
+
imports: string[] = extractImports(source),
|
|
162
|
+
): ReadonlySet<DependencyClass> {
|
|
163
|
+
const classes = new Set<DependencyClass>(classifyImports(imports));
|
|
164
|
+
if (classes.size === 1 && classes.has("fetch-only")) {
|
|
165
|
+
classes.delete("fetch-only");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const allImports = extractAllImportSpecifiers(source);
|
|
169
|
+
if (allImports.some(isServerInfraImport)) {
|
|
170
|
+
classes.add("db");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (/\bBun\.SQL\b|\bBun\.sqlite\b/i.test(source)) {
|
|
174
|
+
classes.add("bun-native");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (
|
|
178
|
+
/(?:^|[^\w$])db\s*`/.test(source) ||
|
|
179
|
+
/\bctx\.deps\.db\b/.test(source) ||
|
|
180
|
+
/\bdb\.(?:query|execute|select|insert|update|delete)\b/.test(source)
|
|
181
|
+
) {
|
|
182
|
+
classes.add("db");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (classes.size === 0) {
|
|
186
|
+
classes.add("fetch-only");
|
|
187
|
+
}
|
|
188
|
+
return classes;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function extractAllImportSpecifiers(source: string): string[] {
|
|
192
|
+
const out = new Set<string>();
|
|
193
|
+
const staticImport = /^\s*import\b[^"']*?["']([^"']+)["']/gm;
|
|
194
|
+
const dynamicImport = /\bimport\(\s*["']([^"']+)["']\s*\)/g;
|
|
195
|
+
for (const re of [staticImport, dynamicImport]) {
|
|
196
|
+
let m: RegExpExecArray | null;
|
|
197
|
+
while ((m = re.exec(source)) !== null) {
|
|
198
|
+
out.add(m[1]!);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return [...out].sort();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isServerInfraImport(specifier: string): boolean {
|
|
205
|
+
const s = specifier.replace(/\\/g, "/").toLowerCase();
|
|
206
|
+
return (
|
|
207
|
+
s === "@/server/infra" ||
|
|
208
|
+
s.startsWith("@/server/infra/") ||
|
|
209
|
+
s === "src/server/infra" ||
|
|
210
|
+
s.startsWith("src/server/infra/") ||
|
|
211
|
+
s.endsWith("/server/infra") ||
|
|
212
|
+
s.includes("/server/infra/")
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function classifyOne(spec: string): DependencyClass | null {
|
|
217
|
+
const s = spec.toLowerCase();
|
|
154
218
|
if (
|
|
155
219
|
s === "bun:sqlite" ||
|
|
156
220
|
s === "bun:ffi" ||
|
|
@@ -167,11 +231,14 @@ function classifyOne(spec: string): DependencyClass | null {
|
|
|
167
231
|
if (s === "node:child_process" || s === "child_process" || s === "node:worker_threads" || s === "worker_threads") {
|
|
168
232
|
return "node-child";
|
|
169
233
|
}
|
|
170
|
-
if (
|
|
171
|
-
/^(postgres|pg|mysql2?|drizzle-orm(\/.*)?|@prisma\/client|prisma|mongodb|mongoose|@neondatabase\/.+|kysely|sqlite3|better-sqlite3|@planetscale\/.+)$/.test(s)
|
|
172
|
-
) {
|
|
173
|
-
return "db";
|
|
174
|
-
}
|
|
234
|
+
if (
|
|
235
|
+
/^(postgres|pg|mysql2?|drizzle-orm(\/.*)?|@prisma\/client|prisma|mongodb|mongoose|@neondatabase\/.+|kysely|sqlite3|better-sqlite3|@planetscale\/.+)$/.test(s)
|
|
236
|
+
) {
|
|
237
|
+
return "db";
|
|
238
|
+
}
|
|
239
|
+
if (s === "@mandujs/core/db" || s.startsWith("@mandujs/core/db/")) {
|
|
240
|
+
return "db";
|
|
241
|
+
}
|
|
175
242
|
if (/^(@anthropic-ai\/sdk|openai|ai|@ai-sdk\/.+|@google\/generative-ai|cohere-ai)$/.test(s)) {
|
|
176
243
|
return "ai-sdk";
|
|
177
244
|
}
|
package/src/filling/context.ts
CHANGED
|
@@ -628,10 +628,23 @@ export class ManduContext {
|
|
|
628
628
|
return this.withCookies(new Response(null, { status: 204 }));
|
|
629
629
|
}
|
|
630
630
|
|
|
631
|
-
/** 400 Bad Request */
|
|
632
|
-
error(message: string, details?: unknown): Response
|
|
633
|
-
|
|
634
|
-
|
|
631
|
+
/** 400 Bad Request, or custom 4xx/5xx error with ctx.error(status, message). */
|
|
632
|
+
error(message: string, details?: unknown): Response;
|
|
633
|
+
error(status: number, message: string, details?: unknown): Response;
|
|
634
|
+
error(
|
|
635
|
+
statusOrMessage: number | string,
|
|
636
|
+
messageOrDetails?: string | unknown,
|
|
637
|
+
maybeDetails?: unknown
|
|
638
|
+
): Response {
|
|
639
|
+
if (typeof statusOrMessage === "number") {
|
|
640
|
+
const status = Number.isInteger(statusOrMessage) && statusOrMessage >= 400 && statusOrMessage <= 599
|
|
641
|
+
? statusOrMessage
|
|
642
|
+
: 400;
|
|
643
|
+
const message = typeof messageOrDetails === "string" ? messageOrDetails : "Error";
|
|
644
|
+
return this.json({ status: "error", message, details: maybeDetails }, status);
|
|
645
|
+
}
|
|
646
|
+
return this.json({ status: "error", message: statusOrMessage, details: messageOrDetails }, 400);
|
|
647
|
+
}
|
|
635
648
|
|
|
636
649
|
/** 401 Unauthorized */
|
|
637
650
|
unauthorized(message: string = "Unauthorized"): Response {
|
package/src/guard/check.ts
CHANGED
|
@@ -30,21 +30,21 @@ export const GENERATED_IMPORT_DOCS_URL =
|
|
|
30
30
|
"https://mandujs.com/docs/architect/generated-access";
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Build the user-facing message for a detected direct
|
|
34
|
-
* import. `specifier` is the literal import string that tripped the
|
|
35
|
-
*
|
|
33
|
+
* Build the user-facing message for a detected direct generated-artifact
|
|
34
|
+
* import. `specifier` is the literal import string that tripped the guard
|
|
35
|
+
* (not the resolved path).
|
|
36
36
|
*
|
|
37
37
|
* This helper is the single source of truth for the message text — both
|
|
38
38
|
* the static Guard pass (`checkInvalidGeneratedImport`) and the bundler
|
|
39
39
|
* plugin (`blockGeneratedImports`) call through it so the two paths
|
|
40
40
|
* cannot drift.
|
|
41
41
|
*/
|
|
42
|
-
export function buildForbiddenGeneratedImportMessage(specifier: string): string {
|
|
43
|
-
return (
|
|
44
|
-
`Direct
|
|
45
|
-
`Use the runtime registry: see ${GENERATED_IMPORT_DOCS_URL}`
|
|
46
|
-
);
|
|
47
|
-
}
|
|
42
|
+
export function buildForbiddenGeneratedImportMessage(specifier: string): string {
|
|
43
|
+
return (
|
|
44
|
+
`Direct generated artifact imports are forbidden: ${specifier}. ` +
|
|
45
|
+
`Use the runtime registry: see ${GENERATED_IMPORT_DOCS_URL}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Shared remediation hint. Points at `getGenerated()` from
|
|
@@ -229,14 +229,17 @@ export class FileAPI {
|
|
|
229
229
|
for (const line of stdout.split("\n")) {
|
|
230
230
|
if (!line.trim()) continue;
|
|
231
231
|
|
|
232
|
-
const statusCode = line.substring(0, 2);
|
|
233
|
-
const gitFilePath = line.substring(3).trim();
|
|
234
|
-
const filePath = this.getProjectRelativePath(gitFilePath, gitRoot);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
232
|
+
const statusCode = line.substring(0, 2);
|
|
233
|
+
const gitFilePath = line.substring(3).trim();
|
|
234
|
+
const filePath = this.getProjectRelativePath(gitFilePath, gitRoot);
|
|
235
|
+
if (!filePath || filePath === ".") {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
changes.push({
|
|
240
|
+
filePath,
|
|
241
|
+
status: parseGitStatus(statusCode),
|
|
242
|
+
});
|
|
240
243
|
}
|
|
241
244
|
|
|
242
245
|
return changes;
|
|
@@ -108,15 +108,20 @@ describe("validateResourceDefinition", () => {
|
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
describe("getPluralName", () => {
|
|
111
|
-
test("should add 's' for simple pluralization", () => {
|
|
112
|
-
const result = getPluralName(userResourceFixture);
|
|
113
|
-
expect(result).toBe("users");
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("should use
|
|
117
|
-
|
|
118
|
-
expect(
|
|
119
|
-
});
|
|
111
|
+
test("should add 's' for simple pluralization", () => {
|
|
112
|
+
const result = getPluralName(userResourceFixture);
|
|
113
|
+
expect(result).toBe("users");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("should use conservative English pluralization for common resource names", () => {
|
|
117
|
+
expect(getPluralName({ ...minimalResourceFixture, name: "party" })).toBe("parties");
|
|
118
|
+
expect(getPluralName({ ...minimalResourceFixture, name: "box" })).toBe("boxes");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("should use custom plural name if provided", () => {
|
|
122
|
+
const result = getPluralName(productResourceFixture);
|
|
123
|
+
expect(result).toBe("inventory");
|
|
124
|
+
});
|
|
120
125
|
|
|
121
126
|
test("should respect autoPlural: false", () => {
|
|
122
127
|
const definition = {
|