swallowkit 1.0.1 → 1.0.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/dist/__tests__/fixtures.d.ts.map +1 -1
- package/dist/__tests__/fixtures.js +3 -0
- package/dist/__tests__/fixtures.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts +2 -1
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +24 -1
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/core/operations/file-session.d.ts +9 -0
- package/dist/core/operations/file-session.d.ts.map +1 -1
- package/dist/core/operations/file-session.js +7 -0
- package/dist/core/operations/file-session.js.map +1 -1
- package/dist/core/operations/scaffold-apply.d.ts.map +1 -1
- package/dist/core/operations/scaffold-apply.js +15 -1
- package/dist/core/operations/scaffold-apply.js.map +1 -1
- package/dist/core/operations/scaffold-machine.d.ts +2 -0
- package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
- package/dist/core/operations/scaffold-machine.js.map +1 -1
- package/dist/core/operations/scaffold-plan.d.ts +3 -1
- package/dist/core/operations/scaffold-plan.d.ts.map +1 -1
- package/dist/core/operations/scaffold-plan.js +7 -0
- package/dist/core/operations/scaffold-plan.js.map +1 -1
- package/dist/core/project/drift.d.ts.map +1 -1
- package/dist/core/project/drift.js +6 -4
- package/dist/core/project/drift.js.map +1 -1
- package/dist/core/scaffold/model-parser.d.ts +10 -1
- package/dist/core/scaffold/model-parser.d.ts.map +1 -1
- package/dist/core/scaffold/model-parser.js +182 -60
- package/dist/core/scaffold/model-parser.js.map +1 -1
- package/dist/core/verify/index.d.ts +2 -0
- package/dist/core/verify/index.d.ts.map +1 -1
- package/dist/core/verify/index.js +26 -8
- package/dist/core/verify/index.js.map +1 -1
- package/dist/machine/index.d.ts.map +1 -1
- package/dist/machine/index.js +16 -9
- package/dist/machine/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/agent-loop-phase34.test.ts +35 -4
- package/src/__tests__/fixtures.ts +3 -0
- package/src/__tests__/model-parser.test.ts +90 -0
- package/src/__tests__/plan-apply.test.ts +152 -0
- package/src/cli/commands/scaffold.ts +30 -2
- package/src/core/operations/file-session.ts +16 -0
- package/src/core/operations/scaffold-apply.ts +21 -1
- package/src/core/operations/scaffold-machine.ts +2 -0
- package/src/core/operations/scaffold-plan.ts +10 -0
- package/src/core/project/drift.ts +9 -5
- package/src/core/scaffold/model-parser.ts +199 -67
- package/src/core/verify/index.ts +29 -8
- package/src/machine/index.ts +18 -4
|
@@ -43,6 +43,13 @@ export interface RecordedFileOperation {
|
|
|
43
43
|
newHash: string | null;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export interface SchemaAnalysisSnapshot {
|
|
47
|
+
parserMode: string;
|
|
48
|
+
semanticFingerprint: string;
|
|
49
|
+
canonicalModel: unknown;
|
|
50
|
+
warnings: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
46
53
|
export function normalizeContentForHash(content: string): string {
|
|
47
54
|
return content.replace(/\r\n/g, "\n");
|
|
48
55
|
}
|
|
@@ -78,6 +85,7 @@ export class FileOperationSession {
|
|
|
78
85
|
private readonly inputFingerprintsByPath = new Map<string, string | null>();
|
|
79
86
|
/** overlay: absolute normalized path -> content (null = deleted in session) */
|
|
80
87
|
private readonly overlay = new Map<string, string | null>();
|
|
88
|
+
private schemaAnalysisValue: SchemaAnalysisSnapshot | null = null;
|
|
81
89
|
|
|
82
90
|
constructor(mode: FileSessionMode, rootDirectory: string = process.cwd()) {
|
|
83
91
|
this.mode = mode;
|
|
@@ -94,6 +102,14 @@ export class FileOperationSession {
|
|
|
94
102
|
);
|
|
95
103
|
}
|
|
96
104
|
|
|
105
|
+
get schemaAnalysis(): SchemaAnalysisSnapshot | null {
|
|
106
|
+
return this.schemaAnalysisValue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setSchemaAnalysis(analysis: SchemaAnalysisSnapshot): void {
|
|
110
|
+
this.schemaAnalysisValue = analysis;
|
|
111
|
+
}
|
|
112
|
+
|
|
97
113
|
addWarning(message: string): void {
|
|
98
114
|
if (!this.warnings.includes(message)) {
|
|
99
115
|
this.warnings.push(message);
|
|
@@ -75,6 +75,23 @@ export async function applyScaffoldOperation(options: ApplyScaffoldOptions): Pro
|
|
|
75
75
|
// 常に最新状態で collect し、競合を検出する(plan 指定時も現況を再確認)
|
|
76
76
|
const currentPlan = await planScaffoldOperation(effectiveOptions);
|
|
77
77
|
|
|
78
|
+
if (basePlan &&
|
|
79
|
+
(!basePlan.schemaAnalysis ||
|
|
80
|
+
basePlan.schemaAnalysis.parserMode !== currentPlan.schemaAnalysis.parserMode ||
|
|
81
|
+
basePlan.schemaAnalysis.semanticFingerprint !== currentPlan.schemaAnalysis.semanticFingerprint)) {
|
|
82
|
+
throw new MachineCommandError(
|
|
83
|
+
"schema-analysis-mismatch",
|
|
84
|
+
`Plan "${basePlan.planId}" used different schema analysis metadata; no artifacts were written.`,
|
|
85
|
+
{
|
|
86
|
+
planId: basePlan.planId,
|
|
87
|
+
expected: basePlan.schemaAnalysis,
|
|
88
|
+
actual: currentPlan.schemaAnalysis,
|
|
89
|
+
nextAction: "Re-run plan scaffold in the same environment, then apply the new plan.",
|
|
90
|
+
},
|
|
91
|
+
"blocked"
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
78
95
|
if (currentPlan.requiresApproval && !options.approve) {
|
|
79
96
|
// 承認待ちの plan は残し、参照できるようにする
|
|
80
97
|
throw new MachineCommandError(
|
|
@@ -88,7 +105,10 @@ export async function applyScaffoldOperation(options: ApplyScaffoldOptions): Pro
|
|
|
88
105
|
);
|
|
89
106
|
}
|
|
90
107
|
|
|
91
|
-
const result = await runMachineScaffoldOperation(
|
|
108
|
+
const result = await runMachineScaffoldOperation({
|
|
109
|
+
...effectiveOptions,
|
|
110
|
+
expectedSchemaAnalysis: currentPlan.schemaAnalysis,
|
|
111
|
+
});
|
|
92
112
|
|
|
93
113
|
// 適用済み plan の状態を破棄
|
|
94
114
|
deletePlanState(currentPlan.planId, projectRoot);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { scaffoldCommand } from "../../cli/commands/scaffold";
|
|
2
|
+
import { SchemaAnalysisSnapshot } from "./file-session";
|
|
2
3
|
import {
|
|
3
4
|
captureConsoleMessagesWithError,
|
|
4
5
|
deriveCapturedErrorMessage,
|
|
@@ -12,6 +13,7 @@ export interface MachineScaffoldOperationOptions {
|
|
|
12
13
|
functionsDir?: string;
|
|
13
14
|
apiDir?: string;
|
|
14
15
|
apiOnly?: boolean;
|
|
16
|
+
expectedSchemaAnalysis?: SchemaAnalysisSnapshot;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export interface MachineScaffoldOperationResult {
|
|
@@ -12,6 +12,7 @@ import { savePlanState } from "../project/state";
|
|
|
12
12
|
import {
|
|
13
13
|
ArtifactOwnership,
|
|
14
14
|
FileOperationSession,
|
|
15
|
+
SchemaAnalysisSnapshot,
|
|
15
16
|
hashFileIfExists,
|
|
16
17
|
runWithFileSession,
|
|
17
18
|
} from "./file-session";
|
|
@@ -52,6 +53,8 @@ export interface ScaffoldPlanData {
|
|
|
52
53
|
swallowkitVersion: string;
|
|
53
54
|
operations: ScaffoldPlanOperation[];
|
|
54
55
|
warnings: string[];
|
|
56
|
+
diagnostics: string[];
|
|
57
|
+
schemaAnalysis: SchemaAnalysisSnapshot;
|
|
55
58
|
conflicts: ScaffoldPlanOperation[];
|
|
56
59
|
requiresApproval: boolean;
|
|
57
60
|
/** project-relative path -> pre-plan content hash (null = did not exist). Used for stale-plan detection. */
|
|
@@ -112,6 +115,10 @@ export async function planScaffoldOperation(options: PlanScaffoldOptions): Promi
|
|
|
112
115
|
const operations: ScaffoldPlanOperation[] = [];
|
|
113
116
|
const fingerprints: Record<string, string | null> = { ...session.inputFingerprints };
|
|
114
117
|
const warnings = [...session.warnings];
|
|
118
|
+
const schemaAnalysis = session.schemaAnalysis;
|
|
119
|
+
if (!schemaAnalysis) {
|
|
120
|
+
throw new Error("Scaffold completed without recording schema analysis metadata.");
|
|
121
|
+
}
|
|
115
122
|
|
|
116
123
|
for (const op of session.operations) {
|
|
117
124
|
fingerprints[op.path] = op.previousHash;
|
|
@@ -173,6 +180,7 @@ export async function planScaffoldOperation(options: PlanScaffoldOptions): Promi
|
|
|
173
180
|
options: { functionsDir: options.functionsDir, apiDir: options.apiDir, apiOnly: options.apiOnly },
|
|
174
181
|
operations: operations.map((op) => ({ path: op.path, action: op.action })),
|
|
175
182
|
fingerprints,
|
|
183
|
+
schemaAnalysis,
|
|
176
184
|
})
|
|
177
185
|
)
|
|
178
186
|
.digest("hex")
|
|
@@ -191,6 +199,8 @@ export async function planScaffoldOperation(options: PlanScaffoldOptions): Promi
|
|
|
191
199
|
swallowkitVersion: getSwallowKitVersion(),
|
|
192
200
|
operations,
|
|
193
201
|
warnings,
|
|
202
|
+
diagnostics: schemaAnalysis.warnings.map((warning) => `warning:${warning}`),
|
|
203
|
+
schemaAnalysis,
|
|
194
204
|
conflicts,
|
|
195
205
|
requiresApproval: conflicts.length > 0,
|
|
196
206
|
fingerprints,
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import * as path from "path";
|
|
13
13
|
import { getSwallowKitVersion } from "../../version";
|
|
14
14
|
import { hashFileIfExists } from "../operations/file-session";
|
|
15
|
+
import { parseModelFile } from "../scaffold/model-parser";
|
|
15
16
|
import { ArtifactRecord, loadArtifactLedger } from "./artifacts";
|
|
16
17
|
import { buildProjectManifest, readProjectManifest, SWALLOWKIT_MANIFEST_PATH } from "./manifest";
|
|
17
18
|
|
|
@@ -84,11 +85,11 @@ function detectArtifactDrift(
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
function detectSchemaDrift(
|
|
88
|
+
async function detectSchemaDrift(
|
|
88
89
|
records: ArtifactRecord[],
|
|
89
90
|
projectRoot: string,
|
|
90
91
|
findings: DriftFinding[]
|
|
91
|
-
): void {
|
|
92
|
+
): Promise<void> {
|
|
92
93
|
const byModel = new Map<string, ArtifactRecord[]>();
|
|
93
94
|
|
|
94
95
|
for (const record of records) {
|
|
@@ -110,7 +111,10 @@ function detectSchemaDrift(
|
|
|
110
111
|
if (currentSchemaHash === null) {
|
|
111
112
|
continue; // モデルファイルが見つからない場合は manifest-drift 側で検出される
|
|
112
113
|
}
|
|
113
|
-
const
|
|
114
|
+
const semanticSchemaHash = (await parseModelFile(modelPath)).semanticFingerprint;
|
|
115
|
+
const staleRecords = modelRecords.filter(
|
|
116
|
+
(record) => record.schemaHash !== semanticSchemaHash && record.schemaHash !== currentSchemaHash
|
|
117
|
+
);
|
|
114
118
|
if (staleRecords.length > 0) {
|
|
115
119
|
const staleSchemaHashes = [...new Set(staleRecords.map((record) => record.schemaHash!))].sort();
|
|
116
120
|
findings.push({
|
|
@@ -120,7 +124,7 @@ function detectSchemaDrift(
|
|
|
120
124
|
path: `shared/models/${kebab}.ts`,
|
|
121
125
|
entity: modelName,
|
|
122
126
|
expected: staleSchemaHashes.join(", "),
|
|
123
|
-
actual:
|
|
127
|
+
actual: semanticSchemaHash,
|
|
124
128
|
repairAction: `Run "swallowkit machine plan scaffold ${kebab}" then apply to regenerate artifacts for ${modelName}.`,
|
|
125
129
|
});
|
|
126
130
|
}
|
|
@@ -182,7 +186,7 @@ export async function detectDrift(projectRoot: string = process.cwd()): Promise<
|
|
|
182
186
|
for (const record of ledger.artifacts) {
|
|
183
187
|
detectArtifactDrift(record, projectRoot, findings);
|
|
184
188
|
}
|
|
185
|
-
detectSchemaDrift(ledger.artifacts, projectRoot, findings);
|
|
189
|
+
await detectSchemaDrift(ledger.artifacts, projectRoot, findings);
|
|
186
190
|
detectGeneratorDrift(ledger.artifacts, findings);
|
|
187
191
|
} else {
|
|
188
192
|
findings.push({
|
|
@@ -8,6 +8,14 @@ import * as path from "path";
|
|
|
8
8
|
import * as ts from "typescript";
|
|
9
9
|
|
|
10
10
|
import { ModelConnectorConfig, ModelAuthPolicy } from "../../types";
|
|
11
|
+
import { MachineCommandError } from "../../machine/errors";
|
|
12
|
+
import { hashContent } from "../operations/file-session";
|
|
13
|
+
|
|
14
|
+
export type SchemaParserMode = "static-ast" | "dynamic";
|
|
15
|
+
|
|
16
|
+
export interface ModelParserOptions {
|
|
17
|
+
executeNode?: typeof execFileSync;
|
|
18
|
+
}
|
|
11
19
|
|
|
12
20
|
export interface ModelInfo {
|
|
13
21
|
name: string; // モデル名(例: "Todo")
|
|
@@ -22,6 +30,9 @@ export interface ModelInfo {
|
|
|
22
30
|
connectorConfig?: ModelConnectorConfig; // コネクタメタデータ(外部データソース用)
|
|
23
31
|
authPolicy?: ModelAuthPolicy; // 認可ポリシー(ロールベースアクセス制御用)
|
|
24
32
|
partitionKey: string; // Cosmos DB パーティションキーパス(デフォルト: "/id")
|
|
33
|
+
parserMode: SchemaParserMode;
|
|
34
|
+
parserWarnings: string[];
|
|
35
|
+
semanticFingerprint: string;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
export interface FieldInfo {
|
|
@@ -55,7 +66,7 @@ export interface NestedSchemaRef {
|
|
|
55
66
|
/**
|
|
56
67
|
* モデルファイルを解析して ModelInfo を返す
|
|
57
68
|
*/
|
|
58
|
-
export async function parseModelFile(modelPath: string): Promise<ModelInfo> {
|
|
69
|
+
export async function parseModelFile(modelPath: string, options: ModelParserOptions = {}): Promise<ModelInfo> {
|
|
59
70
|
if (!fs.existsSync(modelPath)) {
|
|
60
71
|
throw new Error(`Model file not found: ${modelPath}`);
|
|
61
72
|
}
|
|
@@ -100,7 +111,8 @@ export async function parseModelFile(modelPath: string): Promise<ModelInfo> {
|
|
|
100
111
|
const nestedSchemaRefs = detectNestedSchemaRefs(modelPath, content, schemaName);
|
|
101
112
|
|
|
102
113
|
// フィールド情報を抽出(動的インポートを使用)
|
|
103
|
-
const
|
|
114
|
+
const parsedSchema = await extractFieldsFromSchema(modelPath, schemaName, options.executeNode ?? execFileSync);
|
|
115
|
+
const fields = parsedSchema.fields;
|
|
104
116
|
|
|
105
117
|
// ネストスキーマ情報をフィールドにマージ
|
|
106
118
|
mergeNestedSchemaInfo(fields, nestedSchemaRefs);
|
|
@@ -121,7 +133,7 @@ export async function parseModelFile(modelPath: string): Promise<ModelInfo> {
|
|
|
121
133
|
}
|
|
122
134
|
}
|
|
123
135
|
|
|
124
|
-
|
|
136
|
+
const modelInfo: ModelInfo = {
|
|
125
137
|
name: modelName,
|
|
126
138
|
displayName,
|
|
127
139
|
schemaName,
|
|
@@ -134,7 +146,25 @@ export async function parseModelFile(modelPath: string): Promise<ModelInfo> {
|
|
|
134
146
|
partitionKey,
|
|
135
147
|
...(connectorConfig ? { connectorConfig } : {}),
|
|
136
148
|
...(authPolicy ? { authPolicy } : {}),
|
|
149
|
+
parserMode: parsedSchema.mode,
|
|
150
|
+
parserWarnings: parsedSchema.warnings,
|
|
151
|
+
semanticFingerprint: "",
|
|
137
152
|
};
|
|
153
|
+
modelInfo.semanticFingerprint = createModelSemanticFingerprint(modelInfo);
|
|
154
|
+
return modelInfo;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function createModelSemanticFingerprint(model: Omit<ModelInfo, "semanticFingerprint" | "filePath">): string {
|
|
158
|
+
return hashContent(JSON.stringify({
|
|
159
|
+
name: model.name,
|
|
160
|
+
displayName: model.displayName,
|
|
161
|
+
schemaName: model.schemaName,
|
|
162
|
+
fields: model.fields,
|
|
163
|
+
nestedSchemaRefs: model.nestedSchemaRefs,
|
|
164
|
+
partitionKey: model.partitionKey,
|
|
165
|
+
connectorConfig: model.connectorConfig,
|
|
166
|
+
authPolicy: model.authPolicy,
|
|
167
|
+
}));
|
|
138
168
|
}
|
|
139
169
|
|
|
140
170
|
/**
|
|
@@ -360,7 +390,128 @@ function compileModelModule(modelPath: string, directory: string, modules = new
|
|
|
360
390
|
/**
|
|
361
391
|
* Zodスキーマから動的にフィールド情報を抽出
|
|
362
392
|
*/
|
|
363
|
-
|
|
393
|
+
interface ParsedSchemaFields {
|
|
394
|
+
fields: FieldInfo[];
|
|
395
|
+
mode: SchemaParserMode;
|
|
396
|
+
warnings: string[];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function staticParseFields(modelPath: string, schemaName: string): FieldInfo[] | null {
|
|
400
|
+
const source = ts.createSourceFile(modelPath, fs.readFileSync(modelPath, "utf-8"), ts.ScriptTarget.Latest, true);
|
|
401
|
+
const constants = new Map<string, ts.Expression>();
|
|
402
|
+
const importedObjectSchemas = new Set<string>();
|
|
403
|
+
for (const statement of source.statements) {
|
|
404
|
+
if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) &&
|
|
405
|
+
statement.moduleSpecifier.text.startsWith(".") && statement.importClause?.namedBindings &&
|
|
406
|
+
ts.isNamedImports(statement.importClause.namedBindings)) {
|
|
407
|
+
const base = path.resolve(path.dirname(modelPath), statement.moduleSpecifier.text);
|
|
408
|
+
const targetPath = [base, `${base}.ts`, path.join(base, "index.ts")].find(candidate => fs.existsSync(candidate));
|
|
409
|
+
if (targetPath) {
|
|
410
|
+
const targetContent = fs.readFileSync(targetPath, "utf-8");
|
|
411
|
+
for (const element of statement.importClause.namedBindings.elements) {
|
|
412
|
+
const importedName = element.propertyName?.text ?? element.name.text;
|
|
413
|
+
if (new RegExp(`(?:export\\s+)?const\\s+${importedName}\\s*=\\s*z\\.object\\s*\\(`).test(targetContent)) {
|
|
414
|
+
importedObjectSchemas.add(element.name.text);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
420
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
421
|
+
if (ts.isIdentifier(declaration.name) && declaration.initializer) constants.set(declaration.name.text, declaration.initializer);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const schema = constants.get(schemaName);
|
|
426
|
+
if (!schema || !ts.isCallExpression(schema) || !ts.isPropertyAccessExpression(schema.expression) ||
|
|
427
|
+
schema.expression.name.text !== "object" || schema.arguments.length === 0 || !ts.isObjectLiteralExpression(schema.arguments[0])) {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const literalValues = (expression: ts.Expression): string[] | null => {
|
|
432
|
+
let value = expression;
|
|
433
|
+
if (ts.isAsExpression(value)) value = value.expression;
|
|
434
|
+
if (ts.isIdentifier(value)) {
|
|
435
|
+
const resolved = constants.get(value.text);
|
|
436
|
+
return resolved ? literalValues(resolved) : null;
|
|
437
|
+
}
|
|
438
|
+
if (!ts.isArrayLiteralExpression(value)) return null;
|
|
439
|
+
const values: string[] = [];
|
|
440
|
+
for (const element of value.elements) {
|
|
441
|
+
if (ts.isStringLiteral(element) || ts.isNumericLiteral(element)) values.push(element.text);
|
|
442
|
+
else return null;
|
|
443
|
+
}
|
|
444
|
+
return values;
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
const parseExpression = (expression: ts.Expression): Omit<FieldInfo, "name"> | null => {
|
|
448
|
+
if (ts.isIdentifier(expression)) {
|
|
449
|
+
const resolved = constants.get(expression.text);
|
|
450
|
+
if (resolved) return parseExpression(resolved);
|
|
451
|
+
return importedObjectSchemas.has(expression.text)
|
|
452
|
+
? { type: "object", isOptional: false, isNullable: false, isArray: false }
|
|
453
|
+
: null;
|
|
454
|
+
}
|
|
455
|
+
if (!ts.isCallExpression(expression) || !ts.isPropertyAccessExpression(expression.expression)) return null;
|
|
456
|
+
const method = expression.expression.name.text;
|
|
457
|
+
const receiver = expression.expression.expression;
|
|
458
|
+
const wrappers = new Set(["optional", "nullable", "default", "min", "max", "int", "positive", "negative", "nonnegative", "nonpositive", "regex", "email", "url", "uuid", "trim"]);
|
|
459
|
+
if (wrappers.has(method)) {
|
|
460
|
+
const inner = parseExpression(receiver);
|
|
461
|
+
if (!inner) return null;
|
|
462
|
+
if (method === "optional" || method === "default") inner.isOptional = true;
|
|
463
|
+
if (method === "nullable") inner.isNullable = true;
|
|
464
|
+
return inner;
|
|
465
|
+
}
|
|
466
|
+
if (!ts.isIdentifier(receiver) || receiver.text !== "z") return null;
|
|
467
|
+
if (["string", "number", "boolean", "date"].includes(method)) {
|
|
468
|
+
return { type: method, isOptional: false, isNullable: false, isArray: false };
|
|
469
|
+
}
|
|
470
|
+
if (method === "object") return { type: "object", isOptional: false, isNullable: false, isArray: false };
|
|
471
|
+
if (method === "array" && expression.arguments[0]) {
|
|
472
|
+
const element = parseExpression(expression.arguments[0]);
|
|
473
|
+
return element ? { ...element, isArray: true } : null;
|
|
474
|
+
}
|
|
475
|
+
if (method === "enum" && expression.arguments[0]) {
|
|
476
|
+
const enumValues = literalValues(expression.arguments[0]);
|
|
477
|
+
return enumValues ? { type: "string", isOptional: false, isNullable: false, isArray: false, enumValues } : null;
|
|
478
|
+
}
|
|
479
|
+
if (method === "literal" && expression.arguments[0]) {
|
|
480
|
+
const value = expression.arguments[0];
|
|
481
|
+
if (ts.isStringLiteral(value) || ts.isNumericLiteral(value)) {
|
|
482
|
+
return { type: ts.isNumericLiteral(value) ? "number" : "string", isOptional: false, isNullable: false, isArray: false, enumValues: [value.text] };
|
|
483
|
+
}
|
|
484
|
+
if (value.kind === ts.SyntaxKind.TrueKeyword || value.kind === ts.SyntaxKind.FalseKeyword) {
|
|
485
|
+
return { type: "boolean", isOptional: false, isNullable: false, isArray: false, enumValues: [value.kind === ts.SyntaxKind.TrueKeyword ? "true" : "false"] };
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const fields: FieldInfo[] = [];
|
|
492
|
+
for (const property of schema.arguments[0].properties) {
|
|
493
|
+
if (!ts.isPropertyAssignment(property)) return null;
|
|
494
|
+
const name = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) ? property.name.text : null;
|
|
495
|
+
if (!name) return null;
|
|
496
|
+
const parsed = parseExpression(property.initializer);
|
|
497
|
+
if (!parsed) return null;
|
|
498
|
+
const isForeignKey = name.endsWith("Id") && name.length > 2 && parsed.type === "string";
|
|
499
|
+
fields.push({
|
|
500
|
+
name,
|
|
501
|
+
...parsed,
|
|
502
|
+
...(isForeignKey ? { isForeignKey: true, referencedModel: name.slice(0, -2).replace(/^./, char => char.toUpperCase()) } : {}),
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
return fields;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function extractFieldsFromSchema(
|
|
509
|
+
modelPath: string,
|
|
510
|
+
schemaName: string,
|
|
511
|
+
executeNode: typeof execFileSync
|
|
512
|
+
): Promise<ParsedSchemaFields> {
|
|
513
|
+
const staticFields = staticParseFields(modelPath, schemaName);
|
|
514
|
+
if (staticFields) return { fields: staticFields, mode: "static-ast", warnings: [] };
|
|
364
515
|
const fields: FieldInfo[] = [];
|
|
365
516
|
let tempDirectory: string | undefined;
|
|
366
517
|
|
|
@@ -389,7 +540,7 @@ if (isObject) {
|
|
|
389
540
|
const shape = typeof schema._def.shape === 'function' ? schema._def.shape() : schema._def.shape;
|
|
390
541
|
const fields = Object.keys(shape).map(key => {
|
|
391
542
|
const field = shape[key];
|
|
392
|
-
let type =
|
|
543
|
+
let type = undefined;
|
|
393
544
|
let isOptional = false;
|
|
394
545
|
let isNullable = false;
|
|
395
546
|
let isArray = false;
|
|
@@ -457,6 +608,19 @@ if (isObject) {
|
|
|
457
608
|
? fieldDef._def.entries
|
|
458
609
|
: Object.values(fieldDef._def.entries);
|
|
459
610
|
}
|
|
611
|
+
} else if (typeName === 'ZodLiteral') {
|
|
612
|
+
const rawLiteralValues = fieldDef.values || fieldDef._def.values ||
|
|
613
|
+
(fieldDef._def.value !== undefined ? [fieldDef._def.value] : undefined);
|
|
614
|
+
const literalValues = rawLiteralValues instanceof Set ? Array.from(rawLiteralValues) : rawLiteralValues;
|
|
615
|
+
if (Array.isArray(literalValues) && literalValues.length > 0) {
|
|
616
|
+
const literalValue = literalValues[0];
|
|
617
|
+
type = typeof literalValue === 'number' ? 'number' : typeof literalValue === 'boolean' ? 'boolean' : 'string';
|
|
618
|
+
enumValues = literalValues.map(value => String(value));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (!type || ((typeName === 'ZodEnum' || typeName === 'ZodNativeEnum') && (!enumValues || enumValues.length === 0))) {
|
|
623
|
+
throw new Error('Unsupported or ambiguous Zod field "' + key + '" (' + (typeName || 'unknown') + ')');
|
|
460
624
|
}
|
|
461
625
|
|
|
462
626
|
// 外部キー検出: フィールド名が <ModelName>Id のパターンの場合
|
|
@@ -473,13 +637,15 @@ if (isObject) {
|
|
|
473
637
|
});
|
|
474
638
|
|
|
475
639
|
console.log(JSON.stringify(fields));
|
|
640
|
+
} else {
|
|
641
|
+
throw new Error('Exported schema is not a Zod object');
|
|
476
642
|
}
|
|
477
643
|
`;
|
|
478
644
|
|
|
479
645
|
fs.writeFileSync(tempScript, scriptCode, 'utf8');
|
|
480
646
|
|
|
481
647
|
try {
|
|
482
|
-
const result =
|
|
648
|
+
const result = executeNode(process.execPath, [tempScript], {
|
|
483
649
|
encoding: 'utf8',
|
|
484
650
|
cwd: projectRoot,
|
|
485
651
|
});
|
|
@@ -499,71 +665,37 @@ if (isObject) {
|
|
|
499
665
|
}
|
|
500
666
|
}
|
|
501
667
|
} catch (error) {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
668
|
+
const nodeError = error as NodeJS.ErrnoException;
|
|
669
|
+
if (nodeError.code === "EPERM" || nodeError.code === "EACCES" || nodeError.code === "ENOENT") {
|
|
670
|
+
throw new MachineCommandError(
|
|
671
|
+
"schema-evaluation-unavailable",
|
|
672
|
+
`Schema evaluation could not start: ${nodeError.message}`,
|
|
673
|
+
{
|
|
674
|
+
command: process.execPath,
|
|
675
|
+
reason: nodeError.code,
|
|
676
|
+
modelPath,
|
|
677
|
+
diagnostics: ["Dynamic schema evaluation requires permission to start a Node.js child process."],
|
|
678
|
+
nextAction: "Re-run in an environment that permits normal Node.js child process execution.",
|
|
679
|
+
},
|
|
680
|
+
"blocked"
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
throw new MachineCommandError(
|
|
684
|
+
"schema-analysis-unsupported",
|
|
685
|
+
`The model could not be analyzed safely without losing schema semantics: ${error instanceof Error ? error.message : String(error)}`,
|
|
686
|
+
{
|
|
687
|
+
command: process.execPath,
|
|
688
|
+
modelPath,
|
|
689
|
+
diagnostics: ["Lossy regex parsing was not used."],
|
|
690
|
+
nextAction: "Use supported static Zod expressions or re-run where dynamic schema evaluation succeeds.",
|
|
691
|
+
},
|
|
692
|
+
"blocked"
|
|
693
|
+
);
|
|
505
694
|
} finally {
|
|
506
695
|
if (tempDirectory) fs.rmSync(tempDirectory, { recursive: true, force: true });
|
|
507
696
|
}
|
|
508
697
|
|
|
509
|
-
return fields;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/**
|
|
513
|
-
* 正規表現でフィールド情報を抽出(フォールバック用)
|
|
514
|
-
*/
|
|
515
|
-
function extractFieldsWithRegex(modelPath: string, schemaName: string): FieldInfo[] {
|
|
516
|
-
const fields: FieldInfo[] = [];
|
|
517
|
-
const content = fs.readFileSync(modelPath, "utf-8");
|
|
518
|
-
|
|
519
|
-
// z.object({ ... }) の内容を抽出(ネストした括弧に対応)
|
|
520
|
-
const objectContent = extractObjectContent(content, schemaName);
|
|
521
|
-
|
|
522
|
-
if (!objectContent) {
|
|
523
|
-
return fields;
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// 各フィールドを解析
|
|
527
|
-
const fieldRegex = /(\w+)\s*:\s*(z\.\w+)/g;
|
|
528
|
-
let match;
|
|
529
|
-
|
|
530
|
-
while ((match = fieldRegex.exec(objectContent)) !== null) {
|
|
531
|
-
const fieldName = match[1];
|
|
532
|
-
const zodDef = match[2];
|
|
533
|
-
const zodType = zodDef.split('.')[1];
|
|
534
|
-
|
|
535
|
-
const fieldStart = match.index;
|
|
536
|
-
const fieldEnd = objectContent.indexOf(',', fieldStart);
|
|
537
|
-
const fieldDef = fieldEnd > -1
|
|
538
|
-
? objectContent.substring(fieldStart, fieldEnd)
|
|
539
|
-
: objectContent.substring(fieldStart);
|
|
540
|
-
|
|
541
|
-
fields.push({
|
|
542
|
-
name: fieldName,
|
|
543
|
-
type: mapZodTypeToTs(zodType),
|
|
544
|
-
isOptional: fieldDef.includes(".optional()"),
|
|
545
|
-
isNullable: fieldDef.includes(".nullable()"),
|
|
546
|
-
isArray: fieldDef.includes(".array()"),
|
|
547
|
-
});
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
return fields;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
/**
|
|
554
|
-
* Zod 型を TypeScript 型にマッピング
|
|
555
|
-
*/
|
|
556
|
-
function mapZodTypeToTs(zodType: string): string {
|
|
557
|
-
const typeMap: Record<string, string> = {
|
|
558
|
-
string: "string",
|
|
559
|
-
number: "number",
|
|
560
|
-
boolean: "boolean",
|
|
561
|
-
date: "Date",
|
|
562
|
-
object: "object",
|
|
563
|
-
array: "array",
|
|
564
|
-
};
|
|
565
|
-
|
|
566
|
-
return typeMap[zodType] || "any";
|
|
698
|
+
return { fields, mode: "dynamic", warnings: [] };
|
|
567
699
|
}
|
|
568
700
|
|
|
569
701
|
/**
|
package/src/core/verify/index.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface VerifyCheckEvidence {
|
|
|
31
31
|
logTail?: string[];
|
|
32
32
|
/** --compact で抑制された info-severity findings の件数。 */
|
|
33
33
|
suppressedInfoFindings?: number;
|
|
34
|
+
errorCode?: "verification-command-unavailable";
|
|
35
|
+
diagnostics?: string[];
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
export interface VerifyCheckResult {
|
|
@@ -158,8 +160,13 @@ function runCommandCheck(projectRoot: string, spec: CommandCheckSpec): VerifyChe
|
|
|
158
160
|
status: "error",
|
|
159
161
|
durationMs: Date.now() - startedAt,
|
|
160
162
|
fixable: false,
|
|
161
|
-
evidence: {
|
|
162
|
-
|
|
163
|
+
evidence: {
|
|
164
|
+
command: commandLabel,
|
|
165
|
+
logTail: [result.error.message],
|
|
166
|
+
errorCode: "verification-command-unavailable",
|
|
167
|
+
diagnostics: [`Unable to start the verification command: ${result.error.message}`],
|
|
168
|
+
},
|
|
169
|
+
suggestedActions: [`Re-run "${commandLabel}" in an environment that permits child process execution.`],
|
|
163
170
|
};
|
|
164
171
|
}
|
|
165
172
|
|
|
@@ -183,8 +190,13 @@ function runCommandCheck(projectRoot: string, spec: CommandCheckSpec): VerifyChe
|
|
|
183
190
|
status: "error",
|
|
184
191
|
durationMs: Date.now() - startedAt,
|
|
185
192
|
fixable: false,
|
|
186
|
-
evidence: {
|
|
187
|
-
|
|
193
|
+
evidence: {
|
|
194
|
+
command: commandLabel,
|
|
195
|
+
logTail: [error instanceof Error ? error.message : String(error)],
|
|
196
|
+
errorCode: "verification-command-unavailable",
|
|
197
|
+
diagnostics: ["The verification command could not be started in this environment."],
|
|
198
|
+
},
|
|
199
|
+
suggestedActions: [`Re-run "${commandLabel}" in an environment that permits child process execution.`],
|
|
188
200
|
};
|
|
189
201
|
}
|
|
190
202
|
}
|
|
@@ -225,10 +237,17 @@ function runTypecheckCheck(projectRoot: string): VerifyCheckResult {
|
|
|
225
237
|
status: "fail",
|
|
226
238
|
durationMs: Date.now() - startedAt,
|
|
227
239
|
fixable: true,
|
|
228
|
-
evidence: {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
240
|
+
evidence: {
|
|
241
|
+
command: sharedBuild.command,
|
|
242
|
+
logTail: sharedBuild.logTail,
|
|
243
|
+
...(sharedBuild.unavailable ? {
|
|
244
|
+
errorCode: "verification-command-unavailable" as const,
|
|
245
|
+
diagnostics: ["The shared package build command could not be started in this environment."],
|
|
246
|
+
} : {}),
|
|
247
|
+
},
|
|
248
|
+
suggestedActions: sharedBuild.unavailable
|
|
249
|
+
? ["Re-run verification in an environment that permits child process execution."]
|
|
250
|
+
: ["Fix the shared package build errors first; typecheck resolves the shared package from its built dist/."],
|
|
232
251
|
};
|
|
233
252
|
}
|
|
234
253
|
|
|
@@ -264,6 +283,7 @@ interface SharedBuildOutcome {
|
|
|
264
283
|
ok: boolean;
|
|
265
284
|
command?: string;
|
|
266
285
|
logTail?: string[];
|
|
286
|
+
unavailable?: boolean;
|
|
267
287
|
}
|
|
268
288
|
|
|
269
289
|
/** shared/package.json に build script があれば実行する (なければ no-op)。 */
|
|
@@ -292,6 +312,7 @@ function runSharedBuildIfPresent(projectRoot: string): SharedBuildOutcome {
|
|
|
292
312
|
ok,
|
|
293
313
|
command: `${command} run build (in shared/)`,
|
|
294
314
|
logTail: ok ? undefined : tailLines(result.error ? `${output}\n${result.error.message}` : output, 50),
|
|
315
|
+
unavailable: Boolean(result.error),
|
|
295
316
|
};
|
|
296
317
|
}
|
|
297
318
|
|
package/src/machine/index.ts
CHANGED
|
@@ -548,13 +548,27 @@ function createMachineProgram(): Command {
|
|
|
548
548
|
"verify-project",
|
|
549
549
|
async () => {
|
|
550
550
|
const result = await runVerify(options.checks ? options.checks.split(",") : undefined);
|
|
551
|
+
const unavailable = result.checks.find(
|
|
552
|
+
(check) => check.evidence.errorCode === "verification-command-unavailable"
|
|
553
|
+
);
|
|
554
|
+
if (unavailable) {
|
|
555
|
+
throw new MachineCommandError(
|
|
556
|
+
"verification-command-unavailable",
|
|
557
|
+
`Verification could not start "${unavailable.evidence.command ?? unavailable.id}" in this environment.`,
|
|
558
|
+
{
|
|
559
|
+
check: unavailable.id,
|
|
560
|
+
command: unavailable.evidence.command,
|
|
561
|
+
diagnostics: unavailable.evidence.diagnostics ?? unavailable.evidence.logTail,
|
|
562
|
+
nextAction: "Re-run verification in an environment that permits normal child process execution.",
|
|
563
|
+
},
|
|
564
|
+
"blocked"
|
|
565
|
+
);
|
|
566
|
+
}
|
|
551
567
|
return options.compact ? compactVerifyResult(result) : result;
|
|
552
568
|
},
|
|
553
569
|
(result) => ({
|
|
554
|
-
status: result.summary.done ? "complete" : "
|
|
555
|
-
nextActions: result.summary.done
|
|
556
|
-
? []
|
|
557
|
-
: [
|
|
570
|
+
status: result.summary.done ? "complete" : "failed",
|
|
571
|
+
nextActions: result.summary.done ? [] : [
|
|
558
572
|
{
|
|
559
573
|
command: "swallowkit machine explain failure",
|
|
560
574
|
description: "Get evidence and suggested actions for the failed checks.",
|