@mandujs/core 0.52.0 → 0.53.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +38 -2
- package/src/a11y/run-audit.ts +6 -6
- package/src/brain/adapters/oauth-flow.ts +1 -1
- package/src/brain/architecture/analyzer.ts +0 -2
- package/src/brain/credentials.ts +2 -2
- package/src/bundler/__tests__/build-runner.ts +13 -1
- package/src/bundler/__tests__/extended-watch.test.ts +3 -2
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -12
- package/src/bundler/build.test.ts +30 -33
- package/src/bundler/build.ts +2 -1
- package/src/bundler/dev.ts +23 -22
- package/src/bundler/prerender.ts +63 -0
- package/src/bundler/safe-build.test.ts +2 -1
- package/src/bundler/types.ts +5 -3
- package/src/change/history.ts +1 -1
- package/src/change/snapshot.ts +18 -9
- package/src/client/Link.tsx +9 -9
- package/src/client/globals.ts +3 -2
- package/src/client/hooks.ts +0 -4
- package/src/client/hydrate.ts +1 -1
- package/src/client/router.ts +12 -12
- package/src/client/rpc.ts +1 -1
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-sse.ts +5 -5
- package/src/config/mcp-status.ts +0 -1
- package/src/config/validate.ts +1 -1
- package/src/config/watcher.ts +1 -1
- package/src/content/collection.ts +8 -12
- package/src/content/content-layer.ts +5 -5
- package/src/content/data-store.ts +2 -2
- package/src/content/loader-context.ts +0 -1
- package/src/content/loaders/file.ts +1 -1
- package/src/content/loaders/glob.ts +4 -4
- package/src/content/loaders/types.ts +1 -1
- package/src/content/meta-store.ts +1 -1
- package/src/content/types.ts +1 -1
- package/src/contract/define.ts +11 -12
- package/src/contract/index.ts +3 -3
- package/src/contract/protection.ts +2 -2
- package/src/contract/types.ts +1 -1
- package/src/contract/validator.ts +0 -1
- package/src/db/__tests__/db.test.ts +4 -7
- package/src/db/migrations/index.ts +3 -0
- package/src/db/migrations/lock.ts +11 -11
- package/src/deploy/cache.ts +1 -0
- package/src/design/__tests__/agents-link.test.ts +109 -0
- package/src/design/__tests__/lint.test.ts +110 -0
- package/src/design/agents-link.ts +165 -0
- package/src/design/extract.ts +2 -1
- package/src/design/index.ts +16 -0
- package/src/design/lint.ts +209 -0
- package/src/design/patch.ts +0 -1
- package/src/devtools/ai/mcp-connector.ts +24 -24
- package/src/devtools/client/components/kitchen-root.tsx +1 -1
- package/src/devtools/client/components/panel/errors-panel.tsx +1 -1
- package/src/devtools/client/state-manager.ts +1 -1
- package/src/devtools/init.ts +2 -3
- package/src/devtools/server/source-context.ts +1 -1
- package/src/devtools/worker/redaction-worker.ts +8 -4
- package/src/devtools/worker/worker-manager.ts +4 -3
- package/src/error/classifier.ts +1 -1
- package/src/error/domains.ts +1 -1
- package/src/filling/sse.ts +5 -5
- package/src/generator/generate.ts +2 -2
- package/src/guard/analyzer.ts +1 -1
- package/src/guard/ast-analyzer.ts +7 -7
- package/src/guard/auto-correct.ts +2 -2
- package/src/guard/config-guard.ts +0 -2
- package/src/guard/contract-guard.ts +1 -1
- package/src/guard/decision-memory.ts +2 -2
- package/src/guard/design-inline-class.ts +2 -1
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -41
- package/src/guard/negotiation.ts +3 -3
- package/src/guard/reporter.ts +1 -4
- package/src/guard/semantic-slots.ts +2 -2
- package/src/guard/suggestions.ts +2 -2
- package/src/guard/watcher.ts +5 -4
- package/src/index.ts +5 -5
- package/src/intent/index.ts +1 -1
- package/src/kitchen/api/contract-api.ts +1 -1
- package/src/kitchen/api/file-api.ts +3 -3
- package/src/kitchen/kitchen-handler.ts +29 -21
- package/src/lockfile/validate.ts +0 -1
- package/src/openapi/generator.ts +2 -1
- package/src/openapi/openapi.test.ts +43 -14
- package/src/perf/user-marks.ts +1 -1
- package/src/plugins/registry.ts +1 -2
- package/src/plugins/types.ts +1 -1
- package/src/resource/ddl/diff.ts +0 -1
- package/src/resource/generator.ts +1 -1
- package/src/resource/generators/contract.ts +1 -1
- package/src/resource/schema.ts +2 -1
- package/src/router/fs-routes.ts +6 -6
- package/src/router/fs-scanner.ts +1 -1
- package/src/runtime/env.ts +1 -1
- package/src/runtime/image-handler.ts +2 -1
- package/src/runtime/middleware.ts +2 -1
- package/src/runtime/server.ts +67 -60
- package/src/runtime/ssr.ts +3 -3
- package/src/runtime/streaming-ssr.ts +25 -27
- package/src/scheduler/validate.ts +1 -1
- package/src/seo/integration/ssr.ts +2 -3
- package/src/seo/render/opengraph.ts +1 -1
- package/src/seo/resolve/index.ts +0 -4
- package/src/slot/corrector.ts +1 -1
- package/src/slot/validator.ts +4 -4
- package/src/watcher/reporter.ts +1 -1
- package/src/watcher/watcher.ts +19 -19
package/src/client/hooks.ts
CHANGED
package/src/client/hydrate.ts
CHANGED
|
@@ -110,7 +110,7 @@ function noopDispose(): Disposer {
|
|
|
110
110
|
*/
|
|
111
111
|
function scheduleLoad(hydrate: () => void): Disposer {
|
|
112
112
|
let cancelled = false;
|
|
113
|
-
Promise.resolve().then(() => {
|
|
113
|
+
void Promise.resolve().then(() => {
|
|
114
114
|
if (!cancelled) hydrate();
|
|
115
115
|
});
|
|
116
116
|
return () => {
|
package/src/client/router.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* SPA 스타일 네비게이션을 위한 클라이언트 라우터
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
|
|
7
7
|
import { startTransition } from "react";
|
|
8
8
|
import {
|
|
9
9
|
getManduData,
|
|
@@ -404,12 +404,12 @@ export async function navigate(
|
|
|
404
404
|
function handlePopState(event: PopStateEvent): void {
|
|
405
405
|
const state = event.state;
|
|
406
406
|
|
|
407
|
-
if (state?.routeId) {
|
|
408
|
-
// Mandu로 방문한 페이지 - 데이터 다시 fetch
|
|
409
|
-
navigate(window.location.pathname + window.location.search, {
|
|
410
|
-
replace: true,
|
|
411
|
-
scroll: false,
|
|
412
|
-
});
|
|
407
|
+
if (state?.routeId) {
|
|
408
|
+
// Mandu로 방문한 페이지 - 데이터 다시 fetch
|
|
409
|
+
void navigate(window.location.pathname + window.location.search, {
|
|
410
|
+
replace: true,
|
|
411
|
+
scroll: false,
|
|
412
|
+
});
|
|
413
413
|
} else {
|
|
414
414
|
// SPA 네비게이션 이력이 아닌 페이지 — 현재 라우터 상태 유지
|
|
415
415
|
// (getManduRoute()는 SSR 초기값이라 SPA 네비게이션 후에는 stale)
|
|
@@ -576,10 +576,10 @@ function handleLinkClick(event: MouseEvent): void {
|
|
|
576
576
|
// All clear — prevent the default full-page navigation and hand off
|
|
577
577
|
// to the client-side router. `href` preserves the user's original
|
|
578
578
|
// string (relative paths, fragments, query strings) so the router
|
|
579
|
-
// can normalize as needed.
|
|
580
|
-
event.preventDefault();
|
|
581
|
-
navigate(href);
|
|
582
|
-
}
|
|
579
|
+
// can normalize as needed.
|
|
580
|
+
event.preventDefault();
|
|
581
|
+
void navigate(href);
|
|
582
|
+
}
|
|
583
583
|
|
|
584
584
|
// ========== Prefetch ==========
|
|
585
585
|
|
|
@@ -702,7 +702,7 @@ export async function submitAction(
|
|
|
702
702
|
actionData: nextActionData,
|
|
703
703
|
loaderData: result._revalidated ? result.loaderData as unknown : undefined,
|
|
704
704
|
};
|
|
705
|
-
} catch
|
|
705
|
+
} catch {
|
|
706
706
|
if (controller.signal.aborted) return { ok: false };
|
|
707
707
|
|
|
708
708
|
setRouterStateInternal({
|
package/src/client/rpc.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface RpcClientOptions {
|
|
|
50
50
|
* const { id } = await api.post({ body: { title: "New" } });
|
|
51
51
|
* ```
|
|
52
52
|
*/
|
|
53
|
-
export function createClient<
|
|
53
|
+
export function createClient<_TContract = unknown>(
|
|
54
54
|
path: string,
|
|
55
55
|
options?: RpcClientOptions
|
|
56
56
|
): RpcMethods {
|
package/src/client/use-fetch.ts
CHANGED
|
@@ -228,12 +228,12 @@ export function useFetch<T = unknown>(
|
|
|
228
228
|
}
|
|
229
229
|
}, [url, method, stableQuery, stableHeaders, stableBody, cacheTime, cacheKey, transform]);
|
|
230
230
|
|
|
231
|
-
useEffect(() => {
|
|
232
|
-
if (!immediate) return;
|
|
233
|
-
if (data && dedupe) return;
|
|
234
|
-
fetchData();
|
|
235
|
-
return () => { abortRef.current?.abort(); };
|
|
236
|
-
}, [fetchData, immediate, dedupe]);
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (!immediate) return;
|
|
233
|
+
if (data && dedupe) return;
|
|
234
|
+
void fetchData();
|
|
235
|
+
return () => { abortRef.current?.abort(); };
|
|
236
|
+
}, [fetchData, immediate, dedupe]);
|
|
237
237
|
|
|
238
238
|
const mutate = useCallback((updater: T | ((prev: T | null) => T)) => {
|
|
239
239
|
setData(prev => typeof updater === "function" ? (updater as (prev: T | null) => T)(prev) : updater);
|
package/src/client/use-sse.ts
CHANGED
|
@@ -210,7 +210,7 @@ export function useSSE(options: UseSSEOptions = {}): UseSSEReturn {
|
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
// Start reading in a properly-yielding async loop
|
|
213
|
-
(async () => {
|
|
213
|
+
void (async () => {
|
|
214
214
|
const reader = body.getReader();
|
|
215
215
|
const decoder = new TextDecoder();
|
|
216
216
|
|
|
@@ -218,7 +218,7 @@ export function useSSE(options: UseSSEOptions = {}): UseSSEReturn {
|
|
|
218
218
|
while (true) {
|
|
219
219
|
// Check for abort
|
|
220
220
|
if (controller.signal.aborted) {
|
|
221
|
-
reader.cancel();
|
|
221
|
+
await reader.cancel();
|
|
222
222
|
break;
|
|
223
223
|
}
|
|
224
224
|
|
|
@@ -227,7 +227,7 @@ export function useSSE(options: UseSSEOptions = {}): UseSSEReturn {
|
|
|
227
227
|
|
|
228
228
|
// Check for abort again (may have been aborted during read)
|
|
229
229
|
if (controller.signal.aborted) {
|
|
230
|
-
reader.cancel();
|
|
230
|
+
await reader.cancel();
|
|
231
231
|
break;
|
|
232
232
|
}
|
|
233
233
|
|
|
@@ -342,7 +342,7 @@ export async function readStreamWithYield(
|
|
|
342
342
|
try {
|
|
343
343
|
while (true) {
|
|
344
344
|
if (signal?.aborted) {
|
|
345
|
-
reader.cancel();
|
|
345
|
+
await reader.cancel();
|
|
346
346
|
break;
|
|
347
347
|
}
|
|
348
348
|
|
|
@@ -350,7 +350,7 @@ export async function readStreamWithYield(
|
|
|
350
350
|
if (done) break;
|
|
351
351
|
|
|
352
352
|
if (signal?.aborted) {
|
|
353
|
-
reader.cancel();
|
|
353
|
+
await reader.cancel();
|
|
354
354
|
break;
|
|
355
355
|
}
|
|
356
356
|
|
package/src/config/mcp-status.ts
CHANGED
package/src/config/validate.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { isGuardRuleLike, type GuardRule as CustomGuardRule } from "../guard/def
|
|
|
15
15
|
* Creates a schema that warns about unknown keys instead of failing
|
|
16
16
|
* This provides the benefits of .strict() while maintaining compatibility
|
|
17
17
|
*/
|
|
18
|
-
function
|
|
18
|
+
function _strictWithWarnings<T extends z.ZodRawShape>(
|
|
19
19
|
schema: z.ZodObject<T>,
|
|
20
20
|
schemaName: string
|
|
21
21
|
): z.ZodEffects<z.ZodObject<T>> {
|
package/src/config/watcher.ts
CHANGED
|
@@ -607,9 +607,8 @@ interface RenderedMarkdown {
|
|
|
607
607
|
* remark/rehype plugin chain; when any piece is missing, falls back
|
|
608
608
|
* to returning a `<pre>` shell so the caller gets a stable API.
|
|
609
609
|
*
|
|
610
|
-
* We
|
|
611
|
-
*
|
|
612
|
-
* during typecheck — they are NOT in `@mandujs/core` deps by design.
|
|
610
|
+
* We use a variable dynamic import so TS doesn't resolve the optional
|
|
611
|
+
* modules during typecheck — they are NOT in `@mandujs/core` deps by design.
|
|
613
612
|
*
|
|
614
613
|
* The `options.remarkPlugins` / `options.rehypePlugins` arrays let
|
|
615
614
|
* docs sites add `rehype-slug`, `rehype-autolink-headings`, `shiki`,
|
|
@@ -621,15 +620,12 @@ async function renderMarkdownSafe(
|
|
|
621
620
|
): Promise<RenderedMarkdown> {
|
|
622
621
|
const { remarkPlugins = [], rehypePlugins = [], silent = false } = options;
|
|
623
622
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
} catch {
|
|
631
|
-
return null;
|
|
632
|
-
}
|
|
623
|
+
const tryImport = async (id: string): Promise<unknown> => {
|
|
624
|
+
try {
|
|
625
|
+
return await import(id);
|
|
626
|
+
} catch {
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
633
629
|
};
|
|
634
630
|
|
|
635
631
|
const unified = (await tryImport("unified")) as
|
|
@@ -253,11 +253,11 @@ export class ContentLayer {
|
|
|
253
253
|
/**
|
|
254
254
|
* 리소스 정리
|
|
255
255
|
*/
|
|
256
|
-
dispose(): void {
|
|
257
|
-
this.dataStore.dispose();
|
|
258
|
-
this.watcher?.close();
|
|
259
|
-
}
|
|
260
|
-
}
|
|
256
|
+
dispose(): void {
|
|
257
|
+
this.dataStore.dispose();
|
|
258
|
+
void this.watcher?.close();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
261
|
|
|
262
262
|
/**
|
|
263
263
|
* ContentLayer 팩토리
|
|
@@ -131,7 +131,7 @@ export class ContentDataStore {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
this.saveTimer = setTimeout(() => {
|
|
134
|
-
this.save();
|
|
134
|
+
void this.save();
|
|
135
135
|
}, this.options.saveDebounce);
|
|
136
136
|
}
|
|
137
137
|
}
|
|
@@ -217,7 +217,7 @@ export class ContentDataStore {
|
|
|
217
217
|
|
|
218
218
|
// 남은 변경사항 저장
|
|
219
219
|
if (this.dirty && this.options.filePath) {
|
|
220
|
-
this.save();
|
|
220
|
+
void this.save();
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|
|
@@ -98,7 +98,7 @@ export function file(options: FileLoaderOptions): Loader {
|
|
|
98
98
|
name: "file",
|
|
99
99
|
|
|
100
100
|
async load(context: LoaderContext): Promise<void> {
|
|
101
|
-
const { store, config, logger, parseData, generateDigest } = context;
|
|
101
|
+
const { store, config, logger, parseData, generateDigest: _generateDigest } = context;
|
|
102
102
|
|
|
103
103
|
// 절대 경로 계산
|
|
104
104
|
const absolutePath = path.isAbsolute(filePath)
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
|
|
13
13
|
import type { Loader, GlobLoaderOptions, ParsedMarkdown } from "./types";
|
|
14
14
|
import type { LoaderContext, DataEntry } from "../types";
|
|
15
|
-
import {
|
|
15
|
+
import { ParseError } from "../types";
|
|
16
16
|
import { generateFileDigest, combineDigests } from "../digest";
|
|
17
|
-
import { inferParser
|
|
17
|
+
import { inferParser } from "./types";
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as path from "path";
|
|
20
20
|
|
|
@@ -108,7 +108,7 @@ export function glob(options: GlobLoaderOptions): Loader {
|
|
|
108
108
|
name: "glob",
|
|
109
109
|
|
|
110
110
|
async load(context: LoaderContext): Promise<void> {
|
|
111
|
-
const { store, config, logger, parseData, renderMarkdown, watcher } = context;
|
|
111
|
+
const { store, config, logger, parseData: _parseData, renderMarkdown: _renderMarkdown, watcher } = context;
|
|
112
112
|
|
|
113
113
|
// 기본 디렉토리
|
|
114
114
|
const baseDir = base
|
|
@@ -181,7 +181,7 @@ async function processFile(
|
|
|
181
181
|
context: LoaderContext,
|
|
182
182
|
filePath: string,
|
|
183
183
|
id: string,
|
|
184
|
-
|
|
184
|
+
_baseDir: string
|
|
185
185
|
): Promise<void> {
|
|
186
186
|
const { store, parseData, renderMarkdown, logger } = context;
|
|
187
187
|
|
package/src/content/types.ts
CHANGED
package/src/contract/define.ts
CHANGED
|
@@ -27,16 +27,15 @@
|
|
|
27
27
|
* ```
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
getZodTypeName,
|
|
33
|
-
getZodInnerType,
|
|
34
|
-
getZodArrayElementType,
|
|
35
|
-
getZodObjectShape,
|
|
36
|
-
getZodEnumValues,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
} from './zod-utils';
|
|
30
|
+
import type { ZodType } from 'zod';
|
|
31
|
+
import {
|
|
32
|
+
getZodTypeName,
|
|
33
|
+
getZodInnerType,
|
|
34
|
+
getZodArrayElementType,
|
|
35
|
+
getZodObjectShape,
|
|
36
|
+
getZodEnumValues,
|
|
37
|
+
isZodRequired,
|
|
38
|
+
} from './zod-utils';
|
|
40
39
|
|
|
41
40
|
// ============================================================================
|
|
42
41
|
// Types
|
|
@@ -167,7 +166,7 @@ export function generateApiHandler<T extends ContractDefinition>(
|
|
|
167
166
|
throw new Error(`Endpoint "${String(endpointName)}" not found in contract`);
|
|
168
167
|
}
|
|
169
168
|
|
|
170
|
-
const { method, path, input,
|
|
169
|
+
const { method, path, input, description } = endpoint;
|
|
171
170
|
|
|
172
171
|
return `
|
|
173
172
|
import { Mandu } from '@mandujs/core';
|
|
@@ -345,7 +344,7 @@ export function generateOpenAPISpec<T extends ContractDefinition>(
|
|
|
345
344
|
const paths: Record<string, Record<string, unknown>> = {};
|
|
346
345
|
|
|
347
346
|
for (const [name, endpoint] of Object.entries(contract.__endpoints)) {
|
|
348
|
-
const { method, path, input,
|
|
347
|
+
const { method, path, input, description, tags, errors } = endpoint;
|
|
349
348
|
|
|
350
349
|
if (!paths[path]) {
|
|
351
350
|
paths[path] = {};
|
package/src/contract/index.ts
CHANGED
|
@@ -37,10 +37,10 @@ export {
|
|
|
37
37
|
type RpcWireError,
|
|
38
38
|
} from "./rpc";
|
|
39
39
|
|
|
40
|
-
import type { ContractDefinition, ContractInstance
|
|
41
|
-
|
|
40
|
+
import type { ContractDefinition, ContractInstance } from "./schema";
|
|
41
|
+
|
|
42
42
|
import { defineHandler, defineRoute } from "./handler";
|
|
43
|
-
import { createClient, contractFetch
|
|
43
|
+
import { createClient, contractFetch } from "./client";
|
|
44
44
|
import { createClientContract } from "./client-safe";
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -102,7 +102,7 @@ export function extractProtectedFields(
|
|
|
102
102
|
}
|
|
103
103
|
// 민감 필드도 보호 대상
|
|
104
104
|
else if (isSensitiveField(value)) {
|
|
105
|
-
const
|
|
105
|
+
const _meta = getMetadata(value, SENSITIVE_FIELD) as SensitiveFieldMetadata | undefined;
|
|
106
106
|
fields.push({
|
|
107
107
|
path: currentPath,
|
|
108
108
|
reason: "Sensitive field - redacted in logs",
|
|
@@ -187,7 +187,7 @@ export function extractContractProtectedFields(
|
|
|
187
187
|
/**
|
|
188
188
|
* 객체에서 경로로 값 가져오기
|
|
189
189
|
*/
|
|
190
|
-
function
|
|
190
|
+
function _getValueByPath(obj: unknown, path: string): unknown {
|
|
191
191
|
const parts = path.split(".");
|
|
192
192
|
let current: unknown = obj;
|
|
193
193
|
|
package/src/contract/types.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
/**
|
|
15
15
|
* Extract inferred type from Zod schema
|
|
16
16
|
*/
|
|
17
|
-
type
|
|
17
|
+
type _InferZod<T> = T extends z.ZodTypeAny ? z.infer<T> : never;
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Extract inferred type from response schema (supports ResponseSchemaWithExamples)
|
|
@@ -107,13 +107,10 @@ function createFakeCtor(): { Ctor: BunSqlCtor; state: FakeState } {
|
|
|
107
107
|
return Object.assign(call, methods);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// non-this value from a constructor replaces the default return.
|
|
115
|
-
return makeFakeSqlInstance(false) as FakeBunSql;
|
|
116
|
-
}
|
|
110
|
+
function FakeBunSql(config: Record<string, unknown>) {
|
|
111
|
+
state.ctorCalls.push(config);
|
|
112
|
+
// Return the callable instance — NOT the default constructed object.
|
|
113
|
+
return makeFakeSqlInstance(false);
|
|
117
114
|
}
|
|
118
115
|
|
|
119
116
|
return {
|
|
@@ -102,11 +102,11 @@ async function acquirePgAdvisoryLock(
|
|
|
102
102
|
// hash-function reproduction.
|
|
103
103
|
await db`SELECT pg_advisory_lock(hashtext(${lockId})::bigint)`;
|
|
104
104
|
|
|
105
|
-
let
|
|
105
|
+
let _released = false;
|
|
106
106
|
return {
|
|
107
107
|
async release(): Promise<void> {
|
|
108
|
-
if (
|
|
109
|
-
|
|
108
|
+
if (_released) return;
|
|
109
|
+
_released = true;
|
|
110
110
|
try {
|
|
111
111
|
await db`SELECT pg_advisory_unlock(hashtext(${lockId})::bigint)`;
|
|
112
112
|
} catch (err) {
|
|
@@ -147,11 +147,11 @@ async function acquireMysqlLock(
|
|
|
147
147
|
);
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
let
|
|
150
|
+
let _released = false;
|
|
151
151
|
return {
|
|
152
152
|
async release(): Promise<void> {
|
|
153
|
-
if (
|
|
154
|
-
|
|
153
|
+
if (_released) return;
|
|
154
|
+
_released = true;
|
|
155
155
|
try {
|
|
156
156
|
await db`SELECT RELEASE_LOCK(${lockId})`;
|
|
157
157
|
} catch (err) {
|
|
@@ -229,11 +229,11 @@ async function acquireSqliteImmediate(
|
|
|
229
229
|
// Wait for the previous holder to release.
|
|
230
230
|
await previous;
|
|
231
231
|
|
|
232
|
-
let
|
|
232
|
+
let _released = false;
|
|
233
233
|
return {
|
|
234
234
|
async release(): Promise<void> {
|
|
235
|
-
if (
|
|
236
|
-
|
|
235
|
+
if (_released) return;
|
|
236
|
+
_released = true;
|
|
237
237
|
release();
|
|
238
238
|
// If no one queued behind us, drop the entry so the registry
|
|
239
239
|
// doesn't grow unbounded across many apply() cycles.
|
|
@@ -245,10 +245,10 @@ async function acquireSqliteImmediate(
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
function makeNoopLock(): MigrationLock {
|
|
248
|
-
let
|
|
248
|
+
let _released = false;
|
|
249
249
|
return {
|
|
250
250
|
async release(): Promise<void> {
|
|
251
|
-
|
|
251
|
+
_released = true;
|
|
252
252
|
},
|
|
253
253
|
};
|
|
254
254
|
}
|
package/src/deploy/cache.ts
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #245 M5 — AGENTS.md / CLAUDE.md linker tests.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
6
|
+
import { promises as fs } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import {
|
|
10
|
+
buildAgentsDesignBlock,
|
|
11
|
+
DESIGN_LINK_MARKER_END,
|
|
12
|
+
DESIGN_LINK_MARKER_START,
|
|
13
|
+
linkAgentsToDesignMd,
|
|
14
|
+
} from "../agents-link";
|
|
15
|
+
|
|
16
|
+
describe("buildAgentsDesignBlock", () => {
|
|
17
|
+
it("contains markers, the canonical heading, and all 8 MCP tools", () => {
|
|
18
|
+
const block = buildAgentsDesignBlock();
|
|
19
|
+
expect(block).toContain(DESIGN_LINK_MARKER_START);
|
|
20
|
+
expect(block).toContain(DESIGN_LINK_MARKER_END);
|
|
21
|
+
expect(block).toContain("## Design System");
|
|
22
|
+
for (const tool of [
|
|
23
|
+
"mandu.design.get",
|
|
24
|
+
"mandu.design.prompt",
|
|
25
|
+
"mandu.component.list",
|
|
26
|
+
"mandu.design.check",
|
|
27
|
+
"mandu.design.extract",
|
|
28
|
+
"mandu.design.propose",
|
|
29
|
+
"mandu.design.patch",
|
|
30
|
+
"mandu.design.diff_upstream",
|
|
31
|
+
]) {
|
|
32
|
+
expect(block).toContain(tool);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("respects custom DESIGN.md filename", () => {
|
|
37
|
+
expect(buildAgentsDesignBlock("Stripe-DESIGN.md")).toContain("Stripe-DESIGN.md");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("linkAgentsToDesignMd", () => {
|
|
42
|
+
let TEST_DIR: string;
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
TEST_DIR = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-design-link-"));
|
|
45
|
+
});
|
|
46
|
+
afterEach(async () => {
|
|
47
|
+
await fs.rm(TEST_DIR, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("inserts the block at end of an existing AGENTS.md", async () => {
|
|
51
|
+
await fs.writeFile(path.join(TEST_DIR, "AGENTS.md"), "# Existing\n\nSome notes.\n");
|
|
52
|
+
const result = await linkAgentsToDesignMd({ rootDir: TEST_DIR });
|
|
53
|
+
expect(result.changed).toBe(true);
|
|
54
|
+
expect(result.files.find((f) => f.path.endsWith("AGENTS.md"))?.action).toBe("inserted");
|
|
55
|
+
const after = await fs.readFile(path.join(TEST_DIR, "AGENTS.md"), "utf8");
|
|
56
|
+
expect(after).toContain("# Existing");
|
|
57
|
+
expect(after).toContain("Some notes.");
|
|
58
|
+
expect(after).toContain("## Design System");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("is idempotent — running twice produces no diff on the second run", async () => {
|
|
62
|
+
await fs.writeFile(path.join(TEST_DIR, "AGENTS.md"), "# X\n");
|
|
63
|
+
await linkAgentsToDesignMd({ rootDir: TEST_DIR });
|
|
64
|
+
const after1 = await fs.readFile(path.join(TEST_DIR, "AGENTS.md"), "utf8");
|
|
65
|
+
const second = await linkAgentsToDesignMd({ rootDir: TEST_DIR });
|
|
66
|
+
const after2 = await fs.readFile(path.join(TEST_DIR, "AGENTS.md"), "utf8");
|
|
67
|
+
expect(after1).toBe(after2);
|
|
68
|
+
expect(second.files.find((f) => f.path.endsWith("AGENTS.md"))?.action).toBe("unchanged");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("replaces an existing markered block when the body changed", async () => {
|
|
72
|
+
await fs.writeFile(
|
|
73
|
+
path.join(TEST_DIR, "AGENTS.md"),
|
|
74
|
+
`# X\n\n${DESIGN_LINK_MARKER_START}\n\n## Old block\n\n${DESIGN_LINK_MARKER_END}\n`,
|
|
75
|
+
);
|
|
76
|
+
await linkAgentsToDesignMd({ rootDir: TEST_DIR });
|
|
77
|
+
const after = await fs.readFile(path.join(TEST_DIR, "AGENTS.md"), "utf8");
|
|
78
|
+
expect(after).not.toContain("## Old block");
|
|
79
|
+
expect(after).toContain("## Design System");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("touches CLAUDE.md too when both exist", async () => {
|
|
83
|
+
await fs.writeFile(path.join(TEST_DIR, "AGENTS.md"), "# A\n");
|
|
84
|
+
await fs.writeFile(path.join(TEST_DIR, "CLAUDE.md"), "# C\n");
|
|
85
|
+
const result = await linkAgentsToDesignMd({ rootDir: TEST_DIR });
|
|
86
|
+
expect(result.files.filter((f) => f.action === "inserted")).toHaveLength(2);
|
|
87
|
+
expect(await fs.readFile(path.join(TEST_DIR, "CLAUDE.md"), "utf8")).toContain(
|
|
88
|
+
"## Design System",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("creates AGENTS.md when neither file exists and createIfMissing is true", async () => {
|
|
93
|
+
const result = await linkAgentsToDesignMd({
|
|
94
|
+
rootDir: TEST_DIR,
|
|
95
|
+
createIfMissing: true,
|
|
96
|
+
});
|
|
97
|
+
expect(result.changed).toBe(true);
|
|
98
|
+
expect(result.files.find((f) => f.action === "created")).toBeDefined();
|
|
99
|
+
expect(await fs.readFile(path.join(TEST_DIR, "AGENTS.md"), "utf8")).toContain(
|
|
100
|
+
"## Design System",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("is a no-op when neither file exists and createIfMissing is false", async () => {
|
|
105
|
+
const result = await linkAgentsToDesignMd({ rootDir: TEST_DIR });
|
|
106
|
+
expect(result.changed).toBe(false);
|
|
107
|
+
await expect(fs.access(path.join(TEST_DIR, "AGENTS.md"))).rejects.toThrow();
|
|
108
|
+
});
|
|
109
|
+
});
|