@akanjs/devkit 3.2.0-rc.1 → 3.2.0-rc.10
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/CHANGELOG.md +233 -11
- package/agentAssets.ts +1 -1
- package/akanApp/akanApp.host.test.ts +127 -0
- package/akanApp/akanApp.host.ts +50 -57
- package/akanApp/devHostPolicy.test.ts +11 -11
- package/akanApp/devHostPolicy.ts +2 -4
- package/akanContext.ts +2 -5
- package/akanMcpContract.ts +21 -10
- package/akanMcpToolError.ts +16 -0
- package/apiReport/apiDeclarationPrinter.ts +120 -0
- package/apiReport/apiReport.test.ts +497 -0
- package/apiReport/apiReport.ts +232 -0
- package/apiReport/apiReportDiff.ts +126 -0
- package/apiReport/apiReportPolicy.ts +65 -0
- package/apiReport/apiTargets.ts +311 -0
- package/apiReport/changesetBump.ts +19 -0
- package/applicationBuildReporter.test.ts +21 -0
- package/applicationBuildReporter.ts +8 -0
- package/applicationBuildRunner.test.ts +67 -0
- package/applicationBuildRunner.ts +6 -1
- package/artifact/implicitRootLayout.test.ts +4 -0
- package/artifact/implicitRootLayout.ts +13 -1
- package/artifact/storeBootstrap.ts +3 -2
- package/bareErrorScanner.ts +126 -0
- package/biome.base.json +2 -1
- package/codeAgent/skills/akan-debug/SKILL.md +7 -3
- package/codeAgent/skills/akan-err/SKILL.md +7 -0
- package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
- package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
- package/codeAgent/skills/akan-transport/SKILL.md +2 -0
- package/codeAgent/skills/akan-validate/SKILL.md +1 -0
- package/codeAgent/tools/McpClient.ts +28 -6
- package/codeAgent/tools/McpToolPack.ts +1 -1
- package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
- package/commandDecorators/argMeta.ts +4 -0
- package/commandDecorators/cliError.ts +72 -0
- package/commandDecorators/command.ts +186 -96
- package/commandDecorators/commandDecorators.test.ts +240 -17
- package/commandDecorators/exitCodeError.ts +6 -7
- package/commandDecorators/index.ts +1 -0
- package/commandDecorators/targetMeta.ts +3 -0
- package/conventionSet.test.ts +5 -2
- package/csrE2e/csrE2eHarness.fixture.ts +1 -1
- package/errorCodeReference.test.ts +282 -0
- package/errorCodeReference.ts +71 -0
- package/errorCodeScan.ts +551 -0
- package/frontendBuild/allRoutesBuilder.test.ts +16 -4
- package/frontendBuild/allRoutesBuilder.ts +24 -6
- package/frontendBuild/buildRouteClient.test.ts +43 -0
- package/frontendBuild/bundleDefine.ts +2 -0
- package/frontendBuild/clientBuildTypes.ts +5 -2
- package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
- package/frontendBuild/clientEntryDiscovery.ts +53 -9
- package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
- package/frontendBuild/csrArtifactBuilder.ts +9 -3
- package/frontendBuild/csrDevBundler.ts +12 -2
- package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
- package/frontendBuild/csrDevModuleCompiler.ts +37 -8
- package/frontendBuild/csrDevPatcher.test.ts +89 -2
- package/frontendBuild/csrDevPatcher.ts +47 -7
- package/frontendBuild/csrDevTypes.ts +16 -0
- package/frontendBuild/cssCompiler.test.ts +63 -1
- package/frontendBuild/devBarrelImports.ts +132 -0
- package/frontendBuild/devRegistryBundler.ts +19 -4
- package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
- package/frontendBuild/nativeCore.test.ts +148 -0
- package/frontendBuild/precompressArtifacts.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
- package/frontendBuild/ssrDevBundler.ts +13 -2
- package/frontendBuild/vendorImportScanner.test.ts +24 -3
- package/frontendBuild/vendorImportScanner.ts +25 -10
- package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
- package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
- package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
- package/lint/no-import-common-err.grit +31 -0
- package/lint/no-init-fetch-in-client.grit +1 -1
- package/lint/no-web-only-api-outside-webkit.grit +3 -3
- package/maintainabilityBaseline.test.ts +49 -0
- package/maintainabilityBaseline.ts +73 -8
- package/maintainabilityContract.test.ts +302 -1
- package/maintainabilityContract.ts +87 -22
- package/maintainabilityExceptionScanner.test.ts +14 -0
- package/maintainabilityExceptionScanner.ts +5 -1
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.ts +20 -3
- package/moduleFieldNames.test.ts +56 -0
- package/moduleFieldNames.ts +25 -0
- package/package.json +3 -3
- package/preview/PreviewView.ts +32 -11
- package/preview/previewView.test.ts +76 -9
- package/routeJsBudget.test.ts +47 -11
- package/routeJsBudget.ts +88 -25
- package/transforms/barrelAnalyzer.ts +46 -1
- package/transforms/barrelImportsPlugin.ts +61 -45
- package/transforms/transforms.test.ts +67 -1
- package/typecheck/projectTypecheck.ts +5 -1
- package/typecheck/typecheck.proc.ts +3 -2
- package/typecheck/typecheckFailure.ts +6 -1
- package/workflow/artifacts.ts +25 -7
- package/workflow/fieldType.ts +92 -0
- package/workflow/index.ts +2 -0
- package/workflow/plainName.ts +28 -0
- package/workflow/plan.ts +67 -22
- package/workflow/source.ts +0 -5
- package/workspaceGraph.test.ts +200 -3
- package/workspaceGraph.ts +46 -8
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, rm } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { CliError } from "../commandDecorators/cliError";
|
|
3
4
|
import type { TypecheckRecord } from "../typeChecker";
|
|
4
5
|
import { TypecheckFailure } from "./typecheckFailure";
|
|
5
6
|
|
|
@@ -69,7 +70,10 @@ export class ProjectTypecheck {
|
|
|
69
70
|
if (exitCode === 0) return;
|
|
70
71
|
const records = report === "records" ? ProjectTypecheck.#parseRecords(stdout) : null;
|
|
71
72
|
if (records?.length) throw new TypecheckFailure(records);
|
|
72
|
-
|
|
73
|
+
const output = (stderr || stdout).trim();
|
|
74
|
+
if (exitCode === TypecheckFailure.workerExitCode && report === "pretty")
|
|
75
|
+
throw new CliError("checkFailed", output || "Typecheck failed");
|
|
76
|
+
throw new Error(output || `Typecheck failed with exit code ${exitCode}`);
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
static #parseRecords(stdout: string): TypecheckRecord[] | null {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Logger } from "akanjs/common";
|
|
2
2
|
import { TypeChecker } from "../typeChecker";
|
|
3
|
+
import { TypecheckFailure } from "./typecheckFailure";
|
|
3
4
|
|
|
4
5
|
try {
|
|
5
6
|
const filePath = process.env.AKAN_TYPECHECK_FILE;
|
|
@@ -28,11 +29,11 @@ try {
|
|
|
28
29
|
//? Not process.cwd(): that is the realpath (/private/var on macOS) while TypeScript keeps the spelling it was given.
|
|
29
30
|
const root = process.env.AKAN_TYPECHECK_ROOT ?? process.cwd();
|
|
30
31
|
process.stdout.write(JSON.stringify(TypeChecker.toRecords(result.errors, root)));
|
|
31
|
-
process.exit(result.errors.length > 0 ?
|
|
32
|
+
process.exit(result.errors.length > 0 ? TypecheckFailure.workerExitCode : 0);
|
|
32
33
|
}
|
|
33
34
|
if (result.errors.length > 0) {
|
|
34
35
|
console.error(result.message);
|
|
35
|
-
process.exit(
|
|
36
|
+
process.exit(TypecheckFailure.workerExitCode);
|
|
36
37
|
}
|
|
37
38
|
} catch (error) {
|
|
38
39
|
console.error(error instanceof Error ? error.message : String(error));
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { CliError } from "../commandDecorators/cliError";
|
|
1
2
|
import type { TypecheckRecord } from "../typeChecker";
|
|
2
3
|
|
|
3
|
-
export class TypecheckFailure extends
|
|
4
|
+
export class TypecheckFailure extends CliError<"checkFailed"> {
|
|
5
|
+
//? The worker's exit when the project has type errors, so its crash, which exits 1, is told apart.
|
|
6
|
+
static readonly workerExitCode = 3;
|
|
7
|
+
|
|
4
8
|
constructor(readonly records: TypecheckRecord[]) {
|
|
5
9
|
super(
|
|
10
|
+
"checkFailed",
|
|
6
11
|
records
|
|
7
12
|
.slice(0, 20)
|
|
8
13
|
.map((record) => `${record.file ?? "-"}:${record.line}:${record.col} ${record.code} ${record.message}`)
|
package/workflow/artifacts.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
WorkflowNextActionCode,
|
|
17
17
|
WorkflowOverallStatus,
|
|
18
18
|
WorkflowPlan,
|
|
19
|
+
WorkflowRecommendation,
|
|
19
20
|
WorkflowRunArtifact,
|
|
20
21
|
WorkflowRunSource,
|
|
21
22
|
WorkflowValidationCommandResult,
|
|
@@ -94,11 +95,11 @@ export const createWorkflowApplyReport = ({
|
|
|
94
95
|
postApplyChecks?: WorkflowApplyReport["postApplyChecks"];
|
|
95
96
|
recommendations?: WorkflowApplyReport["recommendations"];
|
|
96
97
|
}): WorkflowApplyReport => {
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
action: inferNextActionCode(action, diagnostics)
|
|
101
|
-
|
|
98
|
+
const status = workflowStatus(diagnostics);
|
|
99
|
+
const validationCommands = status === "failed" ? [] : (recommendedValidationCommands ?? commands);
|
|
100
|
+
const nextActionsWithIntent = nextActions
|
|
101
|
+
.map((action) => ({ ...action, action: inferNextActionCode(action, diagnostics) }))
|
|
102
|
+
.filter((action) => status !== "failed" || action.action !== "validate");
|
|
102
103
|
if (sourceChangeBlocked(diagnostics) && !nextActionsWithIntent.some((action) => action.action === "blocked")) {
|
|
103
104
|
nextActionsWithIntent.unshift({
|
|
104
105
|
command: `akan workflow explain ${workflow}`,
|
|
@@ -110,12 +111,13 @@ export const createWorkflowApplyReport = ({
|
|
|
110
111
|
(left, right) => nextActionPriority(left, diagnostics) - nextActionPriority(right, diagnostics),
|
|
111
112
|
);
|
|
112
113
|
const sourceFilesChanged = uniqueBy(changedFiles, (file) => `${file.action}:${file.path}:${file.reason}`);
|
|
113
|
-
const generatedFilesSynced =
|
|
114
|
+
const generatedFilesSynced =
|
|
115
|
+
status === "failed" ? [] : uniqueBy(generatedFiles, (file) => `${file.action}:${file.path}:${file.reason}`);
|
|
114
116
|
return {
|
|
115
117
|
schemaVersion: 1,
|
|
116
118
|
workflow,
|
|
117
119
|
mode,
|
|
118
|
-
status
|
|
120
|
+
status,
|
|
119
121
|
summary: {
|
|
120
122
|
sourceFilesChanged,
|
|
121
123
|
generatedFilesSynced,
|
|
@@ -133,6 +135,11 @@ export const createWorkflowApplyReport = ({
|
|
|
133
135
|
};
|
|
134
136
|
};
|
|
135
137
|
|
|
138
|
+
export const withoutApplyHints = (recommendations: readonly WorkflowRecommendation[]) =>
|
|
139
|
+
recommendations.filter(
|
|
140
|
+
(recommendation) => recommendation.kind !== "auto-apply" && recommendation.kind !== "validation",
|
|
141
|
+
);
|
|
142
|
+
|
|
136
143
|
export const resolveWorkflowCommand = (command: string, plan: WorkflowPlan) => {
|
|
137
144
|
const target = typeof plan.inputs.app === "string" ? plan.inputs.app : "<app-or-lib>";
|
|
138
145
|
return command
|
|
@@ -526,6 +533,17 @@ export const createWorkflowValidationRunReport = async ({
|
|
|
526
533
|
};
|
|
527
534
|
|
|
528
535
|
export const createDryRunWorkflowApplyReport = (plan: WorkflowPlan) => {
|
|
536
|
+
if (workflowStatus(plan.diagnostics) === "failed")
|
|
537
|
+
return createWorkflowApplyReport({
|
|
538
|
+
workflow: plan.workflow,
|
|
539
|
+
mode: "dry-run",
|
|
540
|
+
changedFiles: [],
|
|
541
|
+
generatedFiles: [],
|
|
542
|
+
diagnostics: [...plan.diagnostics],
|
|
543
|
+
recommendations: withoutApplyHints(plan.recommendations),
|
|
544
|
+
nextActions: [],
|
|
545
|
+
plan,
|
|
546
|
+
});
|
|
529
547
|
const changedFiles: PrimitiveChangedFile[] = plan.predictedChanges.flatMap((change) => {
|
|
530
548
|
if (change.action !== "create" && change.action !== "modify") return [];
|
|
531
549
|
return [
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { Sys } from "../commandDecorators";
|
|
3
|
+
import { ensureBaseImport, ensureNamedImport, getSysRoot, moduleComponentName, moduleSourcePaths } from "./source";
|
|
4
|
+
import { sourceFileFor } from "./sourceAst";
|
|
5
|
+
import type { WorkflowDiagnostic } from "./types";
|
|
6
|
+
|
|
7
|
+
const builtInFieldTypes = new Set(["String", "Boolean", "Date", "Map"]);
|
|
8
|
+
const baseFieldTypes = new Set(["Int", "Float", "ID", "Any", "Binary", "ImageResult"]);
|
|
9
|
+
const fieldTypeReference = /^\[*([A-Za-z_$][\w$]*)((?:\.[A-Za-z_$][\w$]*)*)\]*$/;
|
|
10
|
+
|
|
11
|
+
export const ensureBaseTypeImport = (content: string, typeName: string) => {
|
|
12
|
+
const [, name = "", member] = fieldTypeReference.exec(typeName) ?? [];
|
|
13
|
+
if (member || !baseFieldTypes.has(name)) return content;
|
|
14
|
+
return ensureBaseImport(content, name);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const valueNamesOf = (content: string) => {
|
|
18
|
+
const names = new Set<string>();
|
|
19
|
+
for (const statement of sourceFileFor("source.ts", content).statements) {
|
|
20
|
+
if (ts.isImportDeclaration(statement)) {
|
|
21
|
+
const clause = statement.importClause;
|
|
22
|
+
if (!clause || clause.isTypeOnly) continue;
|
|
23
|
+
if (clause.name) names.add(clause.name.text);
|
|
24
|
+
const bindings = clause.namedBindings;
|
|
25
|
+
if (bindings && ts.isNamespaceImport(bindings)) names.add(bindings.name.text);
|
|
26
|
+
else for (const element of bindings?.elements ?? []) if (!element.isTypeOnly) names.add(element.name.text);
|
|
27
|
+
} else if (ts.isVariableStatement(statement)) {
|
|
28
|
+
for (const declaration of statement.declarationList.declarations)
|
|
29
|
+
if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text);
|
|
30
|
+
} else if (
|
|
31
|
+
(ts.isClassDeclaration(statement) || ts.isEnumDeclaration(statement) || ts.isFunctionDeclaration(statement)) &&
|
|
32
|
+
statement.name
|
|
33
|
+
)
|
|
34
|
+
names.add(statement.name.text);
|
|
35
|
+
}
|
|
36
|
+
return names;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const fieldTypeSource = async (
|
|
40
|
+
sys: Sys,
|
|
41
|
+
module: string,
|
|
42
|
+
typeName: string,
|
|
43
|
+
constantContent: string,
|
|
44
|
+
): Promise<{ import?: { name: string; from: string }; diagnostic?: WorkflowDiagnostic }> => {
|
|
45
|
+
const reference = fieldTypeReference.exec(typeName);
|
|
46
|
+
if (!reference)
|
|
47
|
+
return {
|
|
48
|
+
diagnostic: {
|
|
49
|
+
severity: "error",
|
|
50
|
+
code: "primitive-field-type-unknown",
|
|
51
|
+
input: "type",
|
|
52
|
+
message: `Field type "${typeName}" is not a type name field() takes. Name one type, such as Address, or a list of one, such as [Address], then plan this again.`,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
const [, name = "", member] = reference;
|
|
56
|
+
if (builtInFieldTypes.has(name) || baseFieldTypes.has(name) || valueNamesOf(constantContent).has(name)) return {};
|
|
57
|
+
const [scalars, models] = await Promise.all([sys.getScalarModules(), sys.getDatabaseModules()]);
|
|
58
|
+
const scalar = member ? undefined : scalars.find((scalarName) => moduleComponentName(scalarName) === name);
|
|
59
|
+
if (scalar) return { import: { name, from: `../__scalar/${scalar}/${scalar}.constant` } };
|
|
60
|
+
const model = member ? undefined : models.find((modelName) => moduleComponentName(modelName) === name);
|
|
61
|
+
if (model) return { import: { name, from: `../${model}/${model}.constant` } };
|
|
62
|
+
const root = getSysRoot(sys);
|
|
63
|
+
const constantPath = `${root}/${moduleSourcePaths(module).constant}`;
|
|
64
|
+
const named = (kind: string, names: string[]) =>
|
|
65
|
+
names.length ? `Its ${kind} are ${names.map(moduleComponentName).join(", ")}.` : `It has no ${kind}.`;
|
|
66
|
+
return {
|
|
67
|
+
diagnostic: {
|
|
68
|
+
severity: "error",
|
|
69
|
+
code: "primitive-field-type-unknown",
|
|
70
|
+
input: "type",
|
|
71
|
+
message: [
|
|
72
|
+
`Field type "${typeName}" names ${name}, which is not a built-in or akanjs/base type, not imported by ${constantPath}, and not a scalar or model in ${root}.`,
|
|
73
|
+
named("scalars", scalars),
|
|
74
|
+
named("models", models),
|
|
75
|
+
`Create the scalar with the create-scalar workflow first, or import ${name} into ${constantPath}, then plan this again.`,
|
|
76
|
+
].join(" "),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const withFieldTypeImport = async (
|
|
82
|
+
sys: Sys,
|
|
83
|
+
module: string,
|
|
84
|
+
typeName: string,
|
|
85
|
+
content: string,
|
|
86
|
+
diagnostics: WorkflowDiagnostic[],
|
|
87
|
+
) => {
|
|
88
|
+
const withBaseImport = ensureBaseTypeImport(content, typeName);
|
|
89
|
+
const source = await fieldTypeSource(sys, module, typeName, withBaseImport);
|
|
90
|
+
if (source.diagnostic) diagnostics.push(source.diagnostic);
|
|
91
|
+
return source.import ? ensureNamedImport(withBaseImport, source.import.from, source.import.name) : withBaseImport;
|
|
92
|
+
};
|
package/workflow/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export * from "./artifacts";
|
|
2
2
|
export * from "./executor";
|
|
3
|
+
export * from "./fieldType";
|
|
3
4
|
export * from "./guardCatalog";
|
|
4
5
|
export * from "./interactionScaffold";
|
|
5
6
|
export * from "./keyRename";
|
|
6
7
|
export * from "./moduleIndex";
|
|
7
8
|
export * from "./mutationScaffold";
|
|
8
9
|
export * from "./mutationSource";
|
|
10
|
+
export * from "./plainName";
|
|
9
11
|
export * from "./plan";
|
|
10
12
|
export * from "./render";
|
|
11
13
|
export * from "./rolloutGate";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export const sysNamePattern = /^[a-z0-9][a-zA-Z0-9_-]*$/;
|
|
2
|
+
export const identifierPattern = /^_{0,2}[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
3
|
+
const packageNamePattern = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
4
|
+
|
|
5
|
+
export type PlainNameKind = "app" | "lib" | "module" | "service" | "scalar";
|
|
6
|
+
|
|
7
|
+
export const isWorkflowSysName = (value: unknown): value is string =>
|
|
8
|
+
typeof value === "string" && sysNamePattern.test(value);
|
|
9
|
+
export const isWorkflowModuleName = (value: unknown): value is string =>
|
|
10
|
+
typeof value === "string" && identifierPattern.test(value);
|
|
11
|
+
|
|
12
|
+
export const plainNameProblem = (kind: PlainNameKind, name: string) => {
|
|
13
|
+
const isSys = kind === "app" || kind === "lib";
|
|
14
|
+
if (isSys ? isWorkflowSysName(name) : isWorkflowModuleName(name)) return null;
|
|
15
|
+
return `${JSON.stringify(name)} is no ${kind} name; use letters, digits, - and _, starting with ${isSys ? "a lowercase letter or digit" : "a letter"}`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const assertPlainName = (kind: PlainNameKind, name: string) => {
|
|
19
|
+
const problem = plainNameProblem(kind, name);
|
|
20
|
+
if (problem) throw new Error(problem);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const assertPackageName = (name: string) => {
|
|
24
|
+
if (packageNamePattern.test(name)) return;
|
|
25
|
+
throw new Error(
|
|
26
|
+
`${JSON.stringify(name)} is no package name; use name or @scope/name, each of lowercase letters, digits, -, . and _, starting with a letter or digit`,
|
|
27
|
+
);
|
|
28
|
+
};
|
package/workflow/plan.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { capitalize } from "akanjs/common";
|
|
2
2
|
import type { Sys, Workspace } from "../commandDecorators";
|
|
3
|
-
import { workflowPlanApproval } from "./artifacts";
|
|
3
|
+
import { withoutApplyHints, workflowPlanApproval } from "./artifacts";
|
|
4
4
|
import { resolveWorkflowSys } from "./executor";
|
|
5
|
+
import { fieldTypeSource } from "./fieldType";
|
|
5
6
|
import { InteractionScaffold } from "./interactionScaffold";
|
|
6
7
|
import { KeyRename } from "./keyRename";
|
|
7
8
|
import { MutationScaffold } from "./mutationScaffold";
|
|
8
|
-
import {
|
|
9
|
+
import { identifierPattern, sysNamePattern } from "./plainName";
|
|
10
|
+
import { coerceFieldDefault, getSysRoot, moduleSourcePaths, normalizeFieldType } from "./source";
|
|
9
11
|
import type {
|
|
10
12
|
WorkflowDiagnostic,
|
|
11
13
|
WorkflowInputSpec,
|
|
@@ -17,11 +19,10 @@ import type {
|
|
|
17
19
|
WorkflowSurfaceMode,
|
|
18
20
|
} from "./types";
|
|
19
21
|
import { addFieldUiPolicyForType } from "./uiPolicy";
|
|
22
|
+
import { workflowStatus } from "./utils";
|
|
20
23
|
|
|
21
24
|
const surfaceModes = new Set<WorkflowSurfaceMode>(["infer", "include", "skip"]);
|
|
22
25
|
|
|
23
|
-
const sysNamePattern = /^[a-z0-9][a-zA-Z0-9_-]*$/;
|
|
24
|
-
const identifierPattern = /^_{0,2}[a-zA-Z][a-zA-Z0-9_-]*$/;
|
|
25
26
|
//? These inputs become path segments, identifiers and command arguments, so none may carry a separator or a dot.
|
|
26
27
|
const nameInputPatterns: Record<string, RegExp> = {
|
|
27
28
|
app: sysNamePattern,
|
|
@@ -35,11 +36,6 @@ const nameInputPatterns: Record<string, RegExp> = {
|
|
|
35
36
|
interaction: identifierPattern,
|
|
36
37
|
};
|
|
37
38
|
|
|
38
|
-
export const isWorkflowSysName = (value: unknown): value is string =>
|
|
39
|
-
typeof value === "string" && sysNamePattern.test(value);
|
|
40
|
-
export const isWorkflowModuleName = (value: unknown): value is string =>
|
|
41
|
-
typeof value === "string" && identifierPattern.test(value);
|
|
42
|
-
|
|
43
39
|
const parseStringList = (value: unknown) => {
|
|
44
40
|
if (Array.isArray(value)) {
|
|
45
41
|
const values = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
|
@@ -487,7 +483,7 @@ const syssHolding = async (workspace: Workspace, module: string, except: Sys) =>
|
|
|
487
483
|
.map(async (name) => await resolveWorkflowSys(workspace, name)),
|
|
488
484
|
);
|
|
489
485
|
const holding = await Promise.all(
|
|
490
|
-
others.map(async (other) => (other && (await other.
|
|
486
|
+
others.map(async (other) => (other && (await other.getDatabaseModules()).includes(module) ? other : null)),
|
|
491
487
|
);
|
|
492
488
|
return holding.filter((other): other is Sys => other !== null);
|
|
493
489
|
};
|
|
@@ -495,28 +491,64 @@ const syssHolding = async (workspace: Workspace, module: string, except: Sys) =>
|
|
|
495
491
|
const moduleMissing = async (workspace: Workspace, sys: Sys, module: string): Promise<WorkflowDiagnostic | null> => {
|
|
496
492
|
if ((await sys.readdir("lib")).includes(module)) return null;
|
|
497
493
|
const root = getSysRoot(sys);
|
|
498
|
-
const modules =
|
|
499
|
-
const sameName = modules.find((name) => name.
|
|
494
|
+
const modules = await sys.getDatabaseModules();
|
|
495
|
+
const sameName = modules.find((name) => name.toLowerCase() === module.toLowerCase());
|
|
496
|
+
const service = (await sys.getServiceModules()).find(
|
|
497
|
+
(name) => name.replace(/^_/, "").toLowerCase() === module.replace(/^_/, "").toLowerCase(),
|
|
498
|
+
);
|
|
500
499
|
const isScalar = (await sys.getScalarModules()).includes(module);
|
|
501
|
-
const [holder] = sameName || isScalar ? [] : await syssHolding(workspace, module, sys);
|
|
500
|
+
const [holder] = sameName || service || isScalar ? [] : await syssHolding(workspace, module, sys);
|
|
502
501
|
return {
|
|
503
502
|
severity: "error",
|
|
504
503
|
code: "workflow-module-missing",
|
|
505
504
|
input: "module",
|
|
506
505
|
message: [
|
|
507
506
|
`No module named ${module} in ${root}: ${root}/lib/${module} does not exist.`,
|
|
508
|
-
modules.length ? `Its modules are ${modules.join(", ")}.` : `${root} has no modules yet.`,
|
|
507
|
+
modules.length ? `Its database modules are ${modules.join(", ")}.` : `${root} has no database modules yet.`,
|
|
509
508
|
sameName
|
|
510
509
|
? `Did you mean ${sameName}?`
|
|
511
|
-
:
|
|
512
|
-
? `${root}/lib
|
|
513
|
-
:
|
|
514
|
-
? `${module} is
|
|
515
|
-
:
|
|
510
|
+
: service
|
|
511
|
+
? `${root}/lib/${service} is a service module, which stores no model, and this workflow writes only into a database module.`
|
|
512
|
+
: isScalar
|
|
513
|
+
? `${root}/lib/__scalar/${module} is a scalar, and this workflow writes only into a module under ${root}/lib.`
|
|
514
|
+
: holder
|
|
515
|
+
? `${module} is in ${getSysRoot(holder)}, so plan this with --app ${holder.name}.`
|
|
516
|
+
: `If ${module} is a new model, create it with the create-module workflow first, then plan this again.`,
|
|
517
|
+
].join(" "),
|
|
518
|
+
};
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const moduleNotDatabase = async (sys: Sys, spec: WorkflowSpec, module: string): Promise<WorkflowDiagnostic | null> => {
|
|
522
|
+
const databaseModules = await sys.getDatabaseModules();
|
|
523
|
+
if (databaseModules.includes(module)) return null;
|
|
524
|
+
const root = getSysRoot(sys);
|
|
525
|
+
const isServiceModule = module.startsWith("_") && !module.startsWith("__");
|
|
526
|
+
return {
|
|
527
|
+
severity: "error",
|
|
528
|
+
code: "workflow-module-not-database",
|
|
529
|
+
input: "module",
|
|
530
|
+
message: [
|
|
531
|
+
isServiceModule
|
|
532
|
+
? `${module} in ${root} is a service module, which stores no model.`
|
|
533
|
+
: `${root}/lib/${module} is not a database module: it has no ${module}.constant.ts.`,
|
|
534
|
+
`${spec.name} needs a database module, one with lib/<model>/<model>.constant.ts.`,
|
|
535
|
+
databaseModules.length
|
|
536
|
+
? `Its database modules are ${databaseModules.join(", ")}.`
|
|
537
|
+
: `${root} has no database modules yet.`,
|
|
538
|
+
spec.inputs.field
|
|
539
|
+
? "Add the field to one of them, or create its model with the create-module workflow first; a value several models embed is a scalar, made with create-scalar and added to a model as a field of that type."
|
|
540
|
+
: "Plan this on one of them, or create the model with the create-module workflow first.",
|
|
516
541
|
].join(" "),
|
|
517
542
|
};
|
|
518
543
|
};
|
|
519
544
|
|
|
545
|
+
const fieldTypeUnknown = async (sys: Sys, spec: WorkflowSpec, module: string, type: string | null) => {
|
|
546
|
+
if (spec.name !== "add-field" || !type || ["enum", "number", "numeric", "upload"].includes(type.toLowerCase()))
|
|
547
|
+
return null;
|
|
548
|
+
const constant = await sys.readFile(moduleSourcePaths(module).constant);
|
|
549
|
+
return (await fieldTypeSource(sys, module, normalizeFieldType(type), constant)).diagnostic ?? null;
|
|
550
|
+
};
|
|
551
|
+
|
|
520
552
|
export const workflowTargetDiagnostics = async (
|
|
521
553
|
workspace: Workspace,
|
|
522
554
|
spec: WorkflowSpec,
|
|
@@ -539,11 +571,14 @@ export const workflowTargetDiagnostics = async (
|
|
|
539
571
|
const module = planString(inputs, "module");
|
|
540
572
|
const moduleSpec: WorkflowInputSpec | undefined = spec.inputs.module;
|
|
541
573
|
if (!module || !moduleSpec?.mustExist) return [];
|
|
542
|
-
const
|
|
543
|
-
|
|
574
|
+
const refused =
|
|
575
|
+
(await moduleMissing(workspace, sys, module)) ??
|
|
576
|
+
(await moduleNotDatabase(sys, spec, module)) ??
|
|
577
|
+
(await fieldTypeUnknown(sys, spec, module, planString(inputs, "type")));
|
|
578
|
+
return refused ? [refused] : [];
|
|
544
579
|
};
|
|
545
580
|
|
|
546
|
-
|
|
581
|
+
const inspectPlanTarget = async (
|
|
547
582
|
workspace: Workspace,
|
|
548
583
|
spec: WorkflowSpec,
|
|
549
584
|
plan: WorkflowPlan,
|
|
@@ -586,3 +621,13 @@ export const inspectWorkflowPlan = async (
|
|
|
586
621
|
recommendations: [...plan.recommendations, ...inspected.recommendations],
|
|
587
622
|
};
|
|
588
623
|
};
|
|
624
|
+
|
|
625
|
+
export const inspectWorkflowPlan = async (
|
|
626
|
+
workspace: Workspace,
|
|
627
|
+
spec: WorkflowSpec,
|
|
628
|
+
plan: WorkflowPlan,
|
|
629
|
+
): Promise<WorkflowPlan> => {
|
|
630
|
+
const inspected = await inspectPlanTarget(workspace, spec, plan);
|
|
631
|
+
if (workflowStatus(inspected.diagnostics) === "passed") return inspected;
|
|
632
|
+
return { ...inspected, recommendations: withoutApplyHints(inspected.recommendations) };
|
|
633
|
+
};
|
package/workflow/source.ts
CHANGED
|
@@ -238,11 +238,6 @@ export const ensureImportDeclaration = (content: string, moduleSpecifier: string
|
|
|
238
238
|
|
|
239
239
|
export const ensureBaseImport = (content: string, name: string) => ensureNamedImport(content, "akanjs/base", name);
|
|
240
240
|
|
|
241
|
-
export const ensureBaseTypeImport = (content: string, typeName: string) => {
|
|
242
|
-
if (typeName !== "Int" && typeName !== "Float") return content;
|
|
243
|
-
return ensureBaseImport(content, typeName);
|
|
244
|
-
};
|
|
245
|
-
|
|
246
241
|
export type FieldDefaultValue = string | number | boolean | null;
|
|
247
242
|
|
|
248
243
|
const numericDefault = (typeName: "Int" | "Float", rawDefault: FieldDefaultValue): string | null => {
|
package/workspaceGraph.test.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
2
5
|
import type { AkanChangeMap, AkanGraphEdge, AkanGraphNode } from "@akanjs/language";
|
|
6
|
+
import { AkanGraphDiff } from "@akanjs/language";
|
|
7
|
+
import type { Workspace } from "./commandDecorators";
|
|
3
8
|
import { WorkspaceGraph } from "./workspaceGraph";
|
|
4
9
|
|
|
10
|
+
const roots: string[] = [];
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
13
|
+
});
|
|
14
|
+
|
|
5
15
|
const node = (id: string, owner: string | null, change?: AkanGraphNode["change"]): AkanGraphNode => ({
|
|
6
16
|
id,
|
|
7
17
|
kind: id.split(":")[0] as AkanGraphNode["kind"],
|
|
@@ -13,10 +23,15 @@ const node = (id: string, owner: string | null, change?: AkanGraphNode["change"]
|
|
|
13
23
|
at: null,
|
|
14
24
|
...(change ? { change } : {}),
|
|
15
25
|
});
|
|
16
|
-
const edge = (
|
|
26
|
+
const edge = (
|
|
27
|
+
from: string,
|
|
28
|
+
to: string,
|
|
29
|
+
change?: AkanGraphEdge["change"],
|
|
30
|
+
kind: AkanGraphEdge["kind"] = "calls",
|
|
31
|
+
): AkanGraphEdge => ({
|
|
17
32
|
from,
|
|
18
33
|
to,
|
|
19
|
-
kind
|
|
34
|
+
kind,
|
|
20
35
|
label: null,
|
|
21
36
|
count: 1,
|
|
22
37
|
at: null,
|
|
@@ -102,6 +117,60 @@ describe("WorkspaceGraph.answer", () => {
|
|
|
102
117
|
});
|
|
103
118
|
});
|
|
104
119
|
|
|
120
|
+
describe("WorkspaceGraph.answer past the node limit", () => {
|
|
121
|
+
const changeMapOf = (nodes: AkanGraphNode[], edges: AkanGraphEdge[]): AkanChangeMap => ({
|
|
122
|
+
version: 1,
|
|
123
|
+
nodes,
|
|
124
|
+
edges,
|
|
125
|
+
since: "0123456789ab",
|
|
126
|
+
summary: { added: 0, removed: 0, changed: nodes.filter((item) => item.change).length },
|
|
127
|
+
unmapped: [],
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("drops the layout that only wraps a changed route, and keeps the route", () => {
|
|
131
|
+
const fillers = Array.from({ length: 599 }, (_, idx) => node(`method:apps/demo:memo.m${idx}`, "apps/demo"));
|
|
132
|
+
const answer = WorkspaceGraph.answer(
|
|
133
|
+
{
|
|
134
|
+
version: 1,
|
|
135
|
+
nodes: [
|
|
136
|
+
node("layout:apps/demo:/", "apps/demo"),
|
|
137
|
+
node("route:apps/demo:/memo", "apps/demo", "changed"),
|
|
138
|
+
...fillers,
|
|
139
|
+
],
|
|
140
|
+
edges: [edge("layout:apps/demo:/", "route:apps/demo:/memo", undefined, "wraps")],
|
|
141
|
+
},
|
|
142
|
+
{},
|
|
143
|
+
);
|
|
144
|
+
if ("diagnostics" in answer) throw new Error("expected an answer");
|
|
145
|
+
expect(answer.nodes.filter((item) => item.kind !== "method").map(({ id }) => id)).toEqual([
|
|
146
|
+
"route:apps/demo:/memo",
|
|
147
|
+
]);
|
|
148
|
+
expect(answer.edges).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("answers a Change Map whole once its unchanged pages are set aside", () => {
|
|
152
|
+
const routes = Array.from({ length: 10 }, (_, idx) => node(`route:apps/demo:/r${idx}`, "apps/demo"));
|
|
153
|
+
const changed = Array.from({ length: 595 }, (_, idx) =>
|
|
154
|
+
node(`method:apps/demo:memo.m${idx}`, "apps/demo", "changed"),
|
|
155
|
+
);
|
|
156
|
+
const answer = WorkspaceGraph.answerChanges(changeMapOf([...routes, ...changed], []), {});
|
|
157
|
+
if ("diagnostics" in answer) throw new Error("expected an answer");
|
|
158
|
+
expect(answer.nodes).toHaveLength(595);
|
|
159
|
+
expect(answer.summary).toEqual({ added: 0, removed: 0, changed: 595 });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("counts the changed pages it keeps when it names the owners to scope to", () => {
|
|
163
|
+
const routes = Array.from({ length: 650 }, (_, idx) => node(`route:apps/demo:/r${idx}`, "apps/demo", "changed"));
|
|
164
|
+
const answer = WorkspaceGraph.answerChanges(
|
|
165
|
+
changeMapOf([...routes, node("method:libs/shared:file.touch", "libs/shared", "changed")], []),
|
|
166
|
+
{},
|
|
167
|
+
);
|
|
168
|
+
if (!("owners" in answer)) throw new Error("expected a scope request");
|
|
169
|
+
expect(answer.diagnostics.map(({ message }) => message).join("\n")).toContain("651 nodes");
|
|
170
|
+
expect(answer.owners).toEqual({ "apps/demo": 650, "libs/shared": 1 });
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
105
174
|
describe("WorkspaceGraph.scope", () => {
|
|
106
175
|
it("keeps a lib's guard without pulling in every app endpoint that uses it", () => {
|
|
107
176
|
const guard = "guard:libs/shared:Admin";
|
|
@@ -118,4 +187,132 @@ describe("WorkspaceGraph.scope", () => {
|
|
|
118
187
|
expect(scoped.nodes.map(({ id }) => id)).toEqual([guard, own]);
|
|
119
188
|
expect(scoped.edges).toEqual([edge(own, guard)]);
|
|
120
189
|
});
|
|
190
|
+
|
|
191
|
+
it("follows a role the scope's method calls to the implementation its app binds", () => {
|
|
192
|
+
const role = "adaptor::LlmAdaptorRole";
|
|
193
|
+
const binds = (to: string, label: string): AkanGraphEdge => ({ ...edge(role, to), kind: "binds", label });
|
|
194
|
+
const graph = {
|
|
195
|
+
version: 1 as const,
|
|
196
|
+
nodes: [
|
|
197
|
+
{ ...node("method:apps/sceny:plan.draft", "apps/sceny"), module: "plan" },
|
|
198
|
+
{ ...node("method:apps/angelo:chat.reply", "apps/angelo"), module: "chat" },
|
|
199
|
+
{ ...node("method:libs/genai:prompt.run", "libs/genai"), module: "prompt" },
|
|
200
|
+
node(role, null),
|
|
201
|
+
node("adaptor::AnthropicLlm", null),
|
|
202
|
+
node("adaptor:apps/angelo:ChatCompletionsLlm", "apps/angelo"),
|
|
203
|
+
node("adaptor:libs/genai:GenaiLlm", "libs/genai"),
|
|
204
|
+
],
|
|
205
|
+
edges: [
|
|
206
|
+
edge("method:apps/sceny:plan.draft", role),
|
|
207
|
+
edge("method:apps/angelo:chat.reply", role),
|
|
208
|
+
edge("method:libs/genai:prompt.run", role),
|
|
209
|
+
binds("adaptor::AnthropicLlm", "apps/sceny"),
|
|
210
|
+
binds("adaptor:apps/angelo:ChatCompletionsLlm", "apps/angelo"),
|
|
211
|
+
binds("adaptor:libs/genai:GenaiLlm", "libs/genai"),
|
|
212
|
+
],
|
|
213
|
+
};
|
|
214
|
+
const bound = (scope: { app?: string; module?: string }) =>
|
|
215
|
+
WorkspaceGraph.scope(graph, scope)
|
|
216
|
+
.edges.filter(({ kind }) => kind === "binds")
|
|
217
|
+
.map(({ to, label }) => `${label} ${to}`);
|
|
218
|
+
expect(bound({ app: "sceny" })).toEqual(["apps/sceny adaptor::AnthropicLlm"]);
|
|
219
|
+
expect(WorkspaceGraph.scope(graph, { app: "sceny" }).nodes.map(({ id }) => id)).toContain("adaptor::AnthropicLlm");
|
|
220
|
+
expect(bound({ app: "angelo" })).toEqual(["apps/angelo adaptor:apps/angelo:ChatCompletionsLlm"]);
|
|
221
|
+
expect(bound({ app: "genai" })).toEqual([
|
|
222
|
+
"apps/sceny adaptor::AnthropicLlm",
|
|
223
|
+
"apps/angelo adaptor:apps/angelo:ChatCompletionsLlm",
|
|
224
|
+
"libs/genai adaptor:libs/genai:GenaiLlm",
|
|
225
|
+
]);
|
|
226
|
+
expect(bound({ module: "plan" })).toHaveLength(3);
|
|
227
|
+
expect(
|
|
228
|
+
WorkspaceGraph.visibleTo(graph, "sceny")
|
|
229
|
+
.edges.filter(({ kind }) => kind === "binds")
|
|
230
|
+
.map(({ label }) => label),
|
|
231
|
+
).toEqual(["apps/sceny"]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("scopes a Change Map to the binding the app runs, whether or not that binding changed", () => {
|
|
235
|
+
const role = "adaptor::LlmAdaptorRole";
|
|
236
|
+
const versioned = (id: string, owner: string | null, digest: string, module: string | null = null) => ({
|
|
237
|
+
...node(id, owner),
|
|
238
|
+
module,
|
|
239
|
+
digest,
|
|
240
|
+
at: { file: `${owner ?? "akanjs"}/x.ts`, line: 1, column: 1 },
|
|
241
|
+
});
|
|
242
|
+
const binds = (to: string, label: string): AkanGraphEdge => ({ ...edge(role, to), kind: "binds", label });
|
|
243
|
+
const graphOf = (ownBinding: boolean, method: string, lib: string) => ({
|
|
244
|
+
version: 1 as const,
|
|
245
|
+
nodes: [
|
|
246
|
+
versioned("method:apps/sceny:plan.draft", "apps/sceny", method, "plan"),
|
|
247
|
+
node(role, null),
|
|
248
|
+
node("adaptor::AnthropicLlm", null),
|
|
249
|
+
versioned("adaptor:libs/genai:GenaiLlm", "libs/genai", lib),
|
|
250
|
+
],
|
|
251
|
+
edges: [
|
|
252
|
+
edge("method:apps/sceny:plan.draft", role),
|
|
253
|
+
...(ownBinding ? [binds("adaptor::AnthropicLlm", "apps/sceny")] : []),
|
|
254
|
+
binds("adaptor:libs/genai:GenaiLlm", "libs/genai"),
|
|
255
|
+
],
|
|
256
|
+
});
|
|
257
|
+
const scopedBinds = (base: ReturnType<typeof graphOf>, head: ReturnType<typeof graphOf>) => {
|
|
258
|
+
const changes = { ...AkanGraphDiff.between(base, head), since: "0123456789ab", unmapped: [] };
|
|
259
|
+
const answer = WorkspaceGraph.answerChanges(changes, { app: "sceny" });
|
|
260
|
+
if (!("edges" in answer)) throw new Error("expected an answer");
|
|
261
|
+
return answer.edges
|
|
262
|
+
.filter(({ kind }) => kind === "binds")
|
|
263
|
+
.map(({ label, to, change }) => `${change ?? "unchanged"} ${label} ${to}`);
|
|
264
|
+
};
|
|
265
|
+
expect(scopedBinds(graphOf(true, "1", "1"), graphOf(true, "2", "2"))).toEqual([
|
|
266
|
+
"unchanged apps/sceny adaptor::AnthropicLlm",
|
|
267
|
+
]);
|
|
268
|
+
expect(scopedBinds(graphOf(true, "1", "1"), graphOf(false, "1", "2"))).toEqual([
|
|
269
|
+
"unchanged libs/genai adaptor:libs/genai:GenaiLlm",
|
|
270
|
+
"removed apps/sceny adaptor::AnthropicLlm",
|
|
271
|
+
]);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("WorkspaceGraph.changesAt", () => {
|
|
276
|
+
it("maps an edit to a factory in one lib onto the guards it makes in another, and lists no file as unmapped", async () => {
|
|
277
|
+
const root = await mkdtemp(path.join(tmpdir(), "akan-graph-"));
|
|
278
|
+
roots.push(root);
|
|
279
|
+
const git = (...args: string[]) => {
|
|
280
|
+
const result = Bun.spawnSync(
|
|
281
|
+
["git", "-c", "user.name=akan", "-c", "user.email=akan@example.com", "-c", "commit.gpgsign=false", ...args],
|
|
282
|
+
{ cwd: root, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } },
|
|
283
|
+
);
|
|
284
|
+
expect(result.exitCode).toBe(0);
|
|
285
|
+
return result.stdout.toString().trim();
|
|
286
|
+
};
|
|
287
|
+
const write = async (file: string, text: string) => {
|
|
288
|
+
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
|
|
289
|
+
await writeFile(path.join(root, file), text);
|
|
290
|
+
};
|
|
291
|
+
const factory = (body: string) => `import type { Guard } from "akanjs/signal";
|
|
292
|
+
export const resourceGuard = (field: string) =>
|
|
293
|
+
class implements Guard {
|
|
294
|
+
canPass() {
|
|
295
|
+
return ${body};
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
`;
|
|
299
|
+
git("init", "-q", "-b", "main");
|
|
300
|
+
await write("libs/util/srvkit/resource.ts", factory("!!field"));
|
|
301
|
+
await write(
|
|
302
|
+
"apps/demo/srvkit/guards.ts",
|
|
303
|
+
`import { resourceGuard } from "@libs/util/srvkit";\nexport const Member = resourceGuard("member");\n`,
|
|
304
|
+
);
|
|
305
|
+
git("add", "-A");
|
|
306
|
+
git("commit", "-q", "--no-verify", "-m", "base");
|
|
307
|
+
await write("libs/util/srvkit/resource.ts", factory("field.length > 0"));
|
|
308
|
+
const changes = await WorkspaceGraph.changesAt(
|
|
309
|
+
{ workspaceRoot: root } as unknown as Workspace,
|
|
310
|
+
git("rev-parse", "HEAD"),
|
|
311
|
+
);
|
|
312
|
+
expect(changes.nodes.filter((node) => node.change).map(({ id, change }) => `${change} ${id}`)).toEqual([
|
|
313
|
+
"changed guard:apps/demo:Member",
|
|
314
|
+
]);
|
|
315
|
+
expect(changes.unmapped).toEqual([]);
|
|
316
|
+
expect(changes.nodes.some((node) => "sources" in node)).toBe(false);
|
|
317
|
+
});
|
|
121
318
|
});
|