@mandujs/core 0.45.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/brain/adapters/__tests__/resolver.test.ts +37 -21
- package/src/brain/adapters/base.ts +4 -3
- package/src/brain/adapters/index.ts +319 -333
- package/src/brain/adapters/openai-oauth.ts +3 -5
- package/src/brain/brain.ts +8 -11
- package/src/brain/consent.ts +1 -1
- package/src/brain/index.ts +4 -1
- package/src/config/mandu.ts +12 -14
- package/src/config/validate.ts +3 -10
- package/src/deploy/cache.ts +139 -0
- package/src/deploy/index.ts +62 -0
- package/src/deploy/inference/context.ts +173 -0
- package/src/deploy/inference/heuristic.ts +182 -0
- package/src/deploy/intent.ts +173 -0
- package/src/deploy/plan.ts +178 -0
- package/src/runtime/server.ts +30 -0
- package/src/brain/adapters/ollama.ts +0 -235
|
@@ -45,7 +45,7 @@ import type {
|
|
|
45
45
|
CompletionResult,
|
|
46
46
|
} from "../types";
|
|
47
47
|
import type {
|
|
48
|
-
CredentialStore} from "../credentials";
|
|
48
|
+
CredentialStore} from "../credentials";
|
|
49
49
|
import {
|
|
50
50
|
getCredentialStore,
|
|
51
51
|
type StoredToken,
|
|
@@ -89,10 +89,8 @@ export const OPENAI_OAUTH_SCOPE = "openai.chat";
|
|
|
89
89
|
/**
|
|
90
90
|
* Default model — GPT-5.4 (current-generation OpenAI flagship as of
|
|
91
91
|
* 2026-04). Gives brain doctor triage the quality it needs to produce
|
|
92
|
-
* actionable patches
|
|
93
|
-
*
|
|
94
|
-
* `ManduConfig.brain.openai.model` (e.g. set to a cheaper tier for
|
|
95
|
-
* low-stakes automated runs).
|
|
92
|
+
* actionable patches. Override via `ManduConfig.brain.openai.model`
|
|
93
|
+
* (e.g. set to a cheaper tier for low-stakes automated runs).
|
|
96
94
|
*/
|
|
97
95
|
export const OPENAI_DEFAULT_MODEL = "gpt-5.4";
|
|
98
96
|
export const OPENAI_API_BASE = "https://api.openai.com/v1";
|
package/src/brain/brain.ts
CHANGED
|
@@ -20,8 +20,7 @@ import type {
|
|
|
20
20
|
} from "./types";
|
|
21
21
|
import { DEFAULT_BRAIN_POLICY } from "./types";
|
|
22
22
|
import { type LLMAdapter, NoopAdapter } from "./adapters/base";
|
|
23
|
-
import {
|
|
24
|
-
import type { SessionMemory} from "./memory";
|
|
23
|
+
import type { SessionMemory} from "./memory";
|
|
25
24
|
import { getSessionMemory } from "./memory";
|
|
26
25
|
import {
|
|
27
26
|
detectEnvironment,
|
|
@@ -96,15 +95,13 @@ export class Brain {
|
|
|
96
95
|
...options.config,
|
|
97
96
|
};
|
|
98
97
|
|
|
99
|
-
// Set up adapter
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
this.adapter = createOllamaAdapter();
|
|
107
|
-
}
|
|
98
|
+
// Set up adapter — sync default is NoopAdapter (template). The CLI
|
|
99
|
+
// constructs Brain with `options.adapter` populated by
|
|
100
|
+
// `resolveBrainAdapter()` so cloud tiers reach Brain. Surfaces that
|
|
101
|
+
// skip that resolver (server runtime, server-side imports of Brain)
|
|
102
|
+
// get the safe template fallback. Issue #235 removed the local
|
|
103
|
+
// Ollama tier; cloud OAuth is the only non-template adapter now.
|
|
104
|
+
this.adapter = options.adapter ?? new NoopAdapter();
|
|
108
105
|
|
|
109
106
|
// Get session memory
|
|
110
107
|
this.memory = getSessionMemory();
|
package/src/brain/consent.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Privacy invariants:
|
|
15
15
|
* - If consent is not granted, the adapter MUST fall through to the
|
|
16
|
-
*
|
|
16
|
+
* template tier in the resolver (NoopAdapter).
|
|
17
17
|
* - `telemetryOptOut: true` in config bypasses this module entirely —
|
|
18
18
|
* cloud adapters are never constructed in that case.
|
|
19
19
|
* - The consent cache only stores `{ providerId, projectFingerprint,
|
package/src/brain/index.ts
CHANGED
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
// Types
|
|
11
11
|
export * from "./types";
|
|
12
12
|
|
|
13
|
-
// Adapters (
|
|
13
|
+
// Adapters (OpenAI OAuth, Anthropic OAuth, plus the
|
|
14
14
|
// `createBrainAdapter` / `resolveBrainAdapter` resolver — Issue #235).
|
|
15
|
+
// The local Ollama tier was removed; cloud OAuth is the only non-template
|
|
16
|
+
// adapter now. Interactive CLIs prompt `mandu brain login` when the
|
|
17
|
+
// resolver returns `needsLogin: true`.
|
|
15
18
|
export * from "./adapters";
|
|
16
19
|
|
|
17
20
|
// Credential store (OS keychain + filesystem fallback).
|
package/src/config/mandu.ts
CHANGED
|
@@ -644,20 +644,19 @@ export interface ManduConfig {
|
|
|
644
644
|
* Fields:
|
|
645
645
|
* - `adapter` — Which connector to use. Default `"auto"`.
|
|
646
646
|
* Auto resolves in priority order:
|
|
647
|
-
* openai → anthropic →
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
* unreachable (no hard failures).
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
* - `
|
|
654
|
-
*
|
|
655
|
-
* - `ollama.model` — Override the local Ollama model (default
|
|
656
|
-
* `"ministral-3:3b"`).
|
|
647
|
+
* openai → anthropic → template. Explicit
|
|
648
|
+
* values pin the choice but still degrade
|
|
649
|
+
* to template when the dependency is
|
|
650
|
+
* unreachable (no hard failures). Interactive
|
|
651
|
+
* CLIs prompt `mandu brain login` when the
|
|
652
|
+
* fallback fires due to a missing token.
|
|
653
|
+
* - `openai.model` — Override the OpenAI model.
|
|
654
|
+
* - `anthropic.model` — Override the Anthropic model.
|
|
657
655
|
* - `telemetryOptOut` — When `true`, cloud adapters are disabled
|
|
658
656
|
* entirely regardless of stored tokens. The
|
|
659
|
-
* resolver falls to
|
|
660
|
-
* privacy-strict
|
|
657
|
+
* resolver falls to template silently (no
|
|
658
|
+
* login prompt). Use for privacy-strict
|
|
659
|
+
* environments.
|
|
661
660
|
*
|
|
662
661
|
* Omitting this block is equivalent to `{ adapter: "auto" }`.
|
|
663
662
|
*
|
|
@@ -665,10 +664,9 @@ export interface ManduConfig {
|
|
|
665
664
|
* @see `docs/brain/oauth-adapters.md` (when authored).
|
|
666
665
|
*/
|
|
667
666
|
brain?: {
|
|
668
|
-
adapter?: "auto" | "openai" | "anthropic" | "
|
|
667
|
+
adapter?: "auto" | "openai" | "anthropic" | "template";
|
|
669
668
|
openai?: { model?: string };
|
|
670
669
|
anthropic?: { model?: string };
|
|
671
|
-
ollama?: { model?: string; baseUrl?: string };
|
|
672
670
|
telemetryOptOut?: boolean;
|
|
673
671
|
};
|
|
674
672
|
/**
|
package/src/config/validate.ts
CHANGED
|
@@ -663,21 +663,13 @@ const BrainAnthropicConfigSchema = z
|
|
|
663
663
|
})
|
|
664
664
|
.strict();
|
|
665
665
|
|
|
666
|
-
const BrainOllamaConfigSchema = z
|
|
667
|
-
.object({
|
|
668
|
-
model: z.string().min(1).optional(),
|
|
669
|
-
baseUrl: z.string().url().optional(),
|
|
670
|
-
})
|
|
671
|
-
.strict();
|
|
672
|
-
|
|
673
666
|
const BrainConfigSchema = z
|
|
674
667
|
.object({
|
|
675
668
|
adapter: z
|
|
676
|
-
.enum(["auto", "openai", "anthropic", "
|
|
669
|
+
.enum(["auto", "openai", "anthropic", "template"])
|
|
677
670
|
.default("auto"),
|
|
678
671
|
openai: BrainOpenAIConfigSchema.optional(),
|
|
679
672
|
anthropic: BrainAnthropicConfigSchema.optional(),
|
|
680
|
-
ollama: BrainOllamaConfigSchema.optional(),
|
|
681
673
|
telemetryOptOut: z.boolean().optional(),
|
|
682
674
|
})
|
|
683
675
|
.strict();
|
|
@@ -742,7 +734,8 @@ export const ManduConfigSchema = z
|
|
|
742
734
|
* Issue #235 — Brain adapter selection. See {@link BrainConfigSchema}.
|
|
743
735
|
* Optional; omission is equivalent to `{ adapter: "auto" }`, which
|
|
744
736
|
* resolves in priority order: openai-oauth → anthropic-oauth →
|
|
745
|
-
*
|
|
737
|
+
* template (with `needsLogin: true` so interactive CLIs prompt
|
|
738
|
+
* `mandu brain login`).
|
|
746
739
|
*/
|
|
747
740
|
brain: BrainConfigSchema.optional(),
|
|
748
741
|
/**
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy intent cache — `.mandu/deploy.intent.json`.
|
|
3
|
+
*
|
|
4
|
+
* Issue #250 — Phase 1.
|
|
5
|
+
*
|
|
6
|
+
* The cache is the persistence layer between `mandu deploy:plan`
|
|
7
|
+
* (writer) and `mandu deploy --target=...` (reader). It MUST be
|
|
8
|
+
* checked into the repo so deploys are deterministic, brain-free, and
|
|
9
|
+
* reproducible across CI runs.
|
|
10
|
+
*
|
|
11
|
+
* Per-entry shape:
|
|
12
|
+
*
|
|
13
|
+
* - `intent` — fully validated `DeployIntent`.
|
|
14
|
+
* - `source` — `"explicit"` (user wrote `.deploy()` on the
|
|
15
|
+
* route) or `"inferred"` (heuristic / brain).
|
|
16
|
+
* Explicit entries are never overwritten by
|
|
17
|
+
* `deploy:plan` — the inferer treats them as
|
|
18
|
+
* pinned ground truth.
|
|
19
|
+
* - `rationale` — short, human-readable reason. The plan command
|
|
20
|
+
* surfaces this in the diff so reviewers can audit
|
|
21
|
+
* why a route landed on a given runtime.
|
|
22
|
+
* - `sourceHash` — content hash of the route source the intent was
|
|
23
|
+
* inferred from. The next plan call skips
|
|
24
|
+
* re-inference when the hash matches — that's the
|
|
25
|
+
* cost cap on brain calls.
|
|
26
|
+
* - `inferredAt` — ISO timestamp; left unset on `explicit` entries.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
import { promises as fs } from "fs";
|
|
31
|
+
import path from "path";
|
|
32
|
+
import { DeployIntent } from "./intent";
|
|
33
|
+
|
|
34
|
+
export const DeployIntentSource = z.enum(["explicit", "inferred"]);
|
|
35
|
+
export type DeployIntentSource = z.infer<typeof DeployIntentSource>;
|
|
36
|
+
|
|
37
|
+
export const DeployIntentCacheEntry = z.object({
|
|
38
|
+
intent: DeployIntent,
|
|
39
|
+
source: DeployIntentSource,
|
|
40
|
+
rationale: z.string().min(1),
|
|
41
|
+
sourceHash: z.string().min(1),
|
|
42
|
+
inferredAt: z.string().datetime().optional(),
|
|
43
|
+
});
|
|
44
|
+
export type DeployIntentCacheEntry = z.infer<typeof DeployIntentCacheEntry>;
|
|
45
|
+
|
|
46
|
+
export const DeployIntentCache = z.object({
|
|
47
|
+
/** Format version — bump on any breaking schema change. */
|
|
48
|
+
version: z.literal(1),
|
|
49
|
+
/** ISO timestamp of the most recent `deploy:plan` write. */
|
|
50
|
+
generatedAt: z.string().datetime(),
|
|
51
|
+
/**
|
|
52
|
+
* Identifier of the inferer used for the most recent write. Examples:
|
|
53
|
+
* - `"heuristic"` — rule-tree only (no brain).
|
|
54
|
+
* - `"openai:gpt-4.1-mini"` — brain-validated.
|
|
55
|
+
* - `"manual"` — user hand-edited entries.
|
|
56
|
+
*/
|
|
57
|
+
brainModel: z.string().min(1).default("heuristic"),
|
|
58
|
+
/** Map of route id → cache entry. */
|
|
59
|
+
intents: z.record(z.string(), DeployIntentCacheEntry),
|
|
60
|
+
});
|
|
61
|
+
export type DeployIntentCache = z.infer<typeof DeployIntentCache>;
|
|
62
|
+
|
|
63
|
+
/** Filename relative to the project root. */
|
|
64
|
+
export const DEPLOY_INTENT_CACHE_FILE = ".mandu/deploy.intent.json";
|
|
65
|
+
|
|
66
|
+
/** Resolve the absolute path of the cache file for a given project root. */
|
|
67
|
+
export function resolveDeployIntentCachePath(rootDir: string): string {
|
|
68
|
+
return path.join(rootDir, DEPLOY_INTENT_CACHE_FILE);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* An empty (but valid) cache. Returned by `loadDeployIntentCache`
|
|
73
|
+
* when the file is missing — the caller treats that as "every route
|
|
74
|
+
* needs inference".
|
|
75
|
+
*/
|
|
76
|
+
export function emptyDeployIntentCache(): DeployIntentCache {
|
|
77
|
+
return {
|
|
78
|
+
version: 1,
|
|
79
|
+
generatedAt: new Date().toISOString(),
|
|
80
|
+
brainModel: "heuristic",
|
|
81
|
+
intents: {},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read + validate the cache file. A missing file resolves to an empty
|
|
87
|
+
* cache so first-time runs work without ceremony. A malformed file
|
|
88
|
+
* rejects — silently swallowing JSON errors would let a corrupted
|
|
89
|
+
* cache silently produce wrong deploy configs.
|
|
90
|
+
*/
|
|
91
|
+
export async function loadDeployIntentCache(
|
|
92
|
+
rootDir: string,
|
|
93
|
+
): Promise<DeployIntentCache> {
|
|
94
|
+
const file = resolveDeployIntentCachePath(rootDir);
|
|
95
|
+
let raw: string;
|
|
96
|
+
try {
|
|
97
|
+
raw = await fs.readFile(file, "utf8");
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
100
|
+
return emptyDeployIntentCache();
|
|
101
|
+
}
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
let parsed: unknown;
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(raw);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Deploy intent cache is not valid JSON (${file}): ${(err as Error).message}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return DeployIntentCache.parse(parsed);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Atomically write the cache. The intermediate `.tmp` file + rename
|
|
117
|
+
* dance prevents a half-written cache from being read by a concurrent
|
|
118
|
+
* deploy.
|
|
119
|
+
*/
|
|
120
|
+
export async function saveDeployIntentCache(
|
|
121
|
+
rootDir: string,
|
|
122
|
+
cache: DeployIntentCache,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
const validated = DeployIntentCache.parse(cache);
|
|
125
|
+
const file = resolveDeployIntentCachePath(rootDir);
|
|
126
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
127
|
+
const tmp = `${file}.tmp`;
|
|
128
|
+
// Stable key order so the committed file stays diff-friendly.
|
|
129
|
+
const ordered: DeployIntentCache = {
|
|
130
|
+
...validated,
|
|
131
|
+
intents: Object.fromEntries(
|
|
132
|
+
Object.keys(validated.intents)
|
|
133
|
+
.sort()
|
|
134
|
+
.map((id) => [id, validated.intents[id]!]),
|
|
135
|
+
),
|
|
136
|
+
};
|
|
137
|
+
await fs.writeFile(tmp, JSON.stringify(ordered, null, 2) + "\n", "utf8");
|
|
138
|
+
await fs.rename(tmp, file);
|
|
139
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@mandujs/core/deploy` — deploy intent primitive (issue #250 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* The barrel re-exports everything adapters and the CLI need:
|
|
5
|
+
*
|
|
6
|
+
* - **Schemas**: `DeployIntent`, `DeployIntentCache`, runtime / cache /
|
|
7
|
+
* visibility / target enums, plus the partial `DeployIntentInput`
|
|
8
|
+
* used by the `.deploy()` builder call site.
|
|
9
|
+
* - **Cache I/O**: `loadDeployIntentCache`, `saveDeployIntentCache`,
|
|
10
|
+
* `emptyDeployIntentCache`, the cache file path constants.
|
|
11
|
+
* - **Inference**: the offline heuristic and the context builder. The
|
|
12
|
+
* brain inferer plugs in via `planDeploy({ infer: ... })`.
|
|
13
|
+
* - **Plan**: `planDeploy` returns the next cache + a diff in one
|
|
14
|
+
* pure call.
|
|
15
|
+
* - **Validation helpers**: `isStaticIntentValidFor` so adapters can
|
|
16
|
+
* surface configuration errors before deploy.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
DeployIntent,
|
|
21
|
+
DeployIntentInput,
|
|
22
|
+
DeployRuntime,
|
|
23
|
+
DeployCache,
|
|
24
|
+
DeployCacheLifetime,
|
|
25
|
+
DeployVisibility,
|
|
26
|
+
DeployTarget,
|
|
27
|
+
isStaticIntentValidFor,
|
|
28
|
+
} from "./intent";
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
DeployIntentCache,
|
|
32
|
+
DeployIntentCacheEntry,
|
|
33
|
+
DeployIntentSource,
|
|
34
|
+
DEPLOY_INTENT_CACHE_FILE,
|
|
35
|
+
emptyDeployIntentCache,
|
|
36
|
+
loadDeployIntentCache,
|
|
37
|
+
saveDeployIntentCache,
|
|
38
|
+
resolveDeployIntentCachePath,
|
|
39
|
+
} from "./cache";
|
|
40
|
+
|
|
41
|
+
export {
|
|
42
|
+
buildDeployInferenceContext,
|
|
43
|
+
classifyImports,
|
|
44
|
+
extractImports,
|
|
45
|
+
hashSource,
|
|
46
|
+
type DependencyClass,
|
|
47
|
+
type DeployInferenceContext,
|
|
48
|
+
} from "./inference/context";
|
|
49
|
+
|
|
50
|
+
export {
|
|
51
|
+
inferDeployIntentHeuristic,
|
|
52
|
+
type InferenceResult,
|
|
53
|
+
} from "./inference/heuristic";
|
|
54
|
+
|
|
55
|
+
export {
|
|
56
|
+
planDeploy,
|
|
57
|
+
planHasChanges,
|
|
58
|
+
type PlanDeployOptions,
|
|
59
|
+
type PlanDiffEntry,
|
|
60
|
+
type PlanDiffEntryKind,
|
|
61
|
+
type PlanResult,
|
|
62
|
+
} from "./plan";
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy inference context — the bundle of facts an inferer (heuristic
|
|
3
|
+
* or brain) consumes when deciding a route's `DeployIntent`.
|
|
4
|
+
*
|
|
5
|
+
* Issue #250 — Phase 1.
|
|
6
|
+
*
|
|
7
|
+
* The context is intentionally narrow. Anything that's not statically
|
|
8
|
+
* derivable from the route source and manifest entry stays out — we
|
|
9
|
+
* want inference to be deterministic enough that two identical inputs
|
|
10
|
+
* always yield the same intent.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { promises as fs } from "fs";
|
|
14
|
+
import { createHash } from "crypto";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import type { RouteSpec } from "../../spec/schema";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The dependency import classes the heuristic recognises. Mapped from
|
|
20
|
+
* raw import specifiers in `extractImports()` below. The key is the
|
|
21
|
+
* coarsest signal a route exposes about whether it can run at the
|
|
22
|
+
* edge — DB drivers and file IO push the route to `node`/`bun`, while
|
|
23
|
+
* stateless `fetch`/transform code happily runs at the edge.
|
|
24
|
+
*/
|
|
25
|
+
export type DependencyClass =
|
|
26
|
+
| "db" // bun:sqlite, postgres, drizzle, prisma, mongodb, etc.
|
|
27
|
+
| "node-fs" // node:fs, fs/promises, fs
|
|
28
|
+
| "node-net" // node:net, http, dgram
|
|
29
|
+
| "node-child" // node:child_process, worker_threads
|
|
30
|
+
| "bun-native" // bun:sqlite, bun:ffi, Bun.s3, Bun.serve
|
|
31
|
+
| "ai-sdk" // @anthropic-ai/sdk, openai, ai (long latency)
|
|
32
|
+
| "heavy" // sharp, playwright, puppeteer, large native deps
|
|
33
|
+
| "fetch-only" // only http fetch / minor transforms
|
|
34
|
+
| "unknown";
|
|
35
|
+
|
|
36
|
+
export interface DeployInferenceContext {
|
|
37
|
+
/** Stable id from the manifest entry. */
|
|
38
|
+
routeId: string;
|
|
39
|
+
/** URL pattern (`/api/embed`, `/[lang]/page`). */
|
|
40
|
+
pattern: string;
|
|
41
|
+
/** `page` | `api` | `metadata`. */
|
|
42
|
+
kind: RouteSpec["kind"];
|
|
43
|
+
/** Pattern contains `[param]` or `[...rest]`. */
|
|
44
|
+
isDynamic: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Page route exports `generateStaticParams` AND has at least one
|
|
47
|
+
* static-param entry in the manifest. Adapters use this with
|
|
48
|
+
* `runtime: "static"` to know the route is genuinely prerenderable.
|
|
49
|
+
*/
|
|
50
|
+
hasGenerateStaticParams: boolean;
|
|
51
|
+
/** Top-level imports from the handler module (deduped, sorted). */
|
|
52
|
+
imports: string[];
|
|
53
|
+
/** Coarse classification derived from `imports`. */
|
|
54
|
+
dependencyClasses: ReadonlySet<DependencyClass>;
|
|
55
|
+
/** Whether the handler exports a `default` Mandu.filling() instance. */
|
|
56
|
+
exportsFilling: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* SHA-256 of the route source. Used by the cache to skip re-
|
|
59
|
+
* inference when nothing relevant changed.
|
|
60
|
+
*/
|
|
61
|
+
sourceHash: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the inference context for a single route. `rootDir` is the
|
|
66
|
+
* project root; `route.module` is resolved relative to it.
|
|
67
|
+
*/
|
|
68
|
+
export async function buildDeployInferenceContext(
|
|
69
|
+
rootDir: string,
|
|
70
|
+
route: RouteSpec,
|
|
71
|
+
): Promise<DeployInferenceContext> {
|
|
72
|
+
const modulePath = path.resolve(rootDir, route.module);
|
|
73
|
+
let source = "";
|
|
74
|
+
try {
|
|
75
|
+
source = await fs.readFile(modulePath, "utf8");
|
|
76
|
+
} catch {
|
|
77
|
+
// A missing module file is unusual but not fatal — the inferer
|
|
78
|
+
// still gets the manifest metadata and falls back to defaults.
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const imports = extractImports(source);
|
|
82
|
+
const dependencyClasses = classifyImports(imports);
|
|
83
|
+
const isDynamic = /\[(\.\.\.)?[^\]]+\]/.test(route.pattern);
|
|
84
|
+
const hasGenerateStaticParams =
|
|
85
|
+
route.kind === "page" &&
|
|
86
|
+
Array.isArray(route.staticParams) &&
|
|
87
|
+
route.staticParams.length > 0;
|
|
88
|
+
const exportsFilling = /\bMandu\.filling\b|\bfilling\(\)/m.test(source);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
routeId: route.id,
|
|
92
|
+
pattern: route.pattern,
|
|
93
|
+
kind: route.kind,
|
|
94
|
+
isDynamic,
|
|
95
|
+
hasGenerateStaticParams,
|
|
96
|
+
imports,
|
|
97
|
+
dependencyClasses,
|
|
98
|
+
exportsFilling,
|
|
99
|
+
sourceHash: hashSource(source),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Internals ────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/** SHA-256 hex digest. Empty input still produces a stable hash. */
|
|
106
|
+
export function hashSource(source: string): string {
|
|
107
|
+
return createHash("sha256").update(source).digest("hex");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Extract bare import specifiers (`from "..."` / `import("...")`).
|
|
112
|
+
*
|
|
113
|
+
* Best-effort — a TS AST would be more precise but pulling in a parser
|
|
114
|
+
* just for this is heavy. False positives (matches in comments or
|
|
115
|
+
* strings) only mis-classify the route in the heuristic, never break
|
|
116
|
+
* deploys, and the brain inferer can override.
|
|
117
|
+
*/
|
|
118
|
+
export function extractImports(source: string): string[] {
|
|
119
|
+
const out = new Set<string>();
|
|
120
|
+
const staticImport = /^\s*import\b[^"']*?["']([^"']+)["']/gm;
|
|
121
|
+
const dynamicImport = /\bimport\(\s*["']([^"']+)["']\s*\)/g;
|
|
122
|
+
for (const re of [staticImport, dynamicImport]) {
|
|
123
|
+
let m: RegExpExecArray | null;
|
|
124
|
+
while ((m = re.exec(source)) !== null) {
|
|
125
|
+
const spec = m[1]!;
|
|
126
|
+
if (!spec.startsWith(".")) out.add(spec);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return [...out].sort();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Map import specifiers to coarse dependency classes. */
|
|
133
|
+
export function classifyImports(imports: string[]): ReadonlySet<DependencyClass> {
|
|
134
|
+
const classes = new Set<DependencyClass>();
|
|
135
|
+
for (const spec of imports) {
|
|
136
|
+
const cls = classifyOne(spec);
|
|
137
|
+
if (cls) classes.add(cls);
|
|
138
|
+
}
|
|
139
|
+
if (classes.size === 0) classes.add("fetch-only");
|
|
140
|
+
return classes;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function classifyOne(spec: string): DependencyClass | null {
|
|
144
|
+
const s = spec.toLowerCase();
|
|
145
|
+
if (
|
|
146
|
+
s === "bun:sqlite" ||
|
|
147
|
+
s === "bun:ffi" ||
|
|
148
|
+
s.startsWith("bun:")
|
|
149
|
+
) {
|
|
150
|
+
return "bun-native";
|
|
151
|
+
}
|
|
152
|
+
if (s === "fs" || s === "fs/promises" || s === "node:fs" || s === "node:fs/promises" || s === "node:path") {
|
|
153
|
+
return "node-fs";
|
|
154
|
+
}
|
|
155
|
+
if (s === "net" || s === "node:net" || s === "node:dgram" || s === "node:tls") {
|
|
156
|
+
return "node-net";
|
|
157
|
+
}
|
|
158
|
+
if (s === "node:child_process" || s === "child_process" || s === "node:worker_threads" || s === "worker_threads") {
|
|
159
|
+
return "node-child";
|
|
160
|
+
}
|
|
161
|
+
if (
|
|
162
|
+
/^(postgres|pg|mysql2?|drizzle-orm(\/.*)?|@prisma\/client|prisma|mongodb|mongoose|@neondatabase\/.+|kysely|sqlite3|better-sqlite3|@planetscale\/.+)$/.test(s)
|
|
163
|
+
) {
|
|
164
|
+
return "db";
|
|
165
|
+
}
|
|
166
|
+
if (/^(@anthropic-ai\/sdk|openai|ai|@ai-sdk\/.+|@google\/generative-ai|cohere-ai)$/.test(s)) {
|
|
167
|
+
return "ai-sdk";
|
|
168
|
+
}
|
|
169
|
+
if (/^(sharp|playwright|playwright-core|puppeteer|@sparticuz\/.+|canvas|jimp)$/.test(s)) {
|
|
170
|
+
return "heavy";
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|