swallowkit 1.0.0 → 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.
Files changed (49) hide show
  1. package/dist/__tests__/fixtures.d.ts.map +1 -1
  2. package/dist/__tests__/fixtures.js +3 -0
  3. package/dist/__tests__/fixtures.js.map +1 -1
  4. package/dist/cli/commands/scaffold.d.ts +2 -1
  5. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  6. package/dist/cli/commands/scaffold.js +24 -1
  7. package/dist/cli/commands/scaffold.js.map +1 -1
  8. package/dist/core/operations/file-session.d.ts +9 -0
  9. package/dist/core/operations/file-session.d.ts.map +1 -1
  10. package/dist/core/operations/file-session.js +7 -0
  11. package/dist/core/operations/file-session.js.map +1 -1
  12. package/dist/core/operations/scaffold-apply.d.ts.map +1 -1
  13. package/dist/core/operations/scaffold-apply.js +15 -1
  14. package/dist/core/operations/scaffold-apply.js.map +1 -1
  15. package/dist/core/operations/scaffold-machine.d.ts +2 -0
  16. package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
  17. package/dist/core/operations/scaffold-machine.js.map +1 -1
  18. package/dist/core/operations/scaffold-plan.d.ts +3 -1
  19. package/dist/core/operations/scaffold-plan.d.ts.map +1 -1
  20. package/dist/core/operations/scaffold-plan.js +7 -0
  21. package/dist/core/operations/scaffold-plan.js.map +1 -1
  22. package/dist/core/project/drift.d.ts.map +1 -1
  23. package/dist/core/project/drift.js +17 -13
  24. package/dist/core/project/drift.js.map +1 -1
  25. package/dist/core/scaffold/model-parser.d.ts +10 -1
  26. package/dist/core/scaffold/model-parser.d.ts.map +1 -1
  27. package/dist/core/scaffold/model-parser.js +182 -60
  28. package/dist/core/scaffold/model-parser.js.map +1 -1
  29. package/dist/core/verify/index.d.ts +2 -0
  30. package/dist/core/verify/index.d.ts.map +1 -1
  31. package/dist/core/verify/index.js +29 -11
  32. package/dist/core/verify/index.js.map +1 -1
  33. package/dist/machine/index.d.ts.map +1 -1
  34. package/dist/machine/index.js +16 -9
  35. package/dist/machine/index.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/__tests__/agent-loop-phase34.test.ts +138 -1
  38. package/src/__tests__/fixtures.ts +3 -0
  39. package/src/__tests__/model-parser.test.ts +90 -0
  40. package/src/__tests__/plan-apply.test.ts +152 -0
  41. package/src/cli/commands/scaffold.ts +30 -2
  42. package/src/core/operations/file-session.ts +16 -0
  43. package/src/core/operations/scaffold-apply.ts +21 -1
  44. package/src/core/operations/scaffold-machine.ts +2 -0
  45. package/src/core/operations/scaffold-plan.ts +10 -0
  46. package/src/core/project/drift.ts +19 -14
  47. package/src/core/scaffold/model-parser.ts +199 -67
  48. package/src/core/verify/index.ts +32 -11
  49. package/src/machine/index.ts +18 -4
@@ -9,6 +9,7 @@ jest.mock("child_process", () => {
9
9
  const actual = jest.requireActual<typeof import("child_process")>("child_process");
10
10
  return {
11
11
  ...actual,
12
+ execFileSync: jest.fn(actual.execFileSync),
12
13
  spawn: jest.fn(),
13
14
  spawnSync: jest.fn(),
14
15
  };
@@ -143,6 +144,8 @@ describe("plan / apply / drift / verify machine commands", () => {
143
144
  beforeEach(() => {
144
145
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-agent-"));
145
146
  process.chdir(tempDir);
147
+ const actual = jest.requireActual<typeof import("child_process")>("child_process");
148
+ (childProcess.execFileSync as unknown as ReturnType<typeof jest.fn>).mockImplementation(actual.execFileSync);
146
149
  });
147
150
 
148
151
  afterEach(() => {
@@ -161,6 +164,16 @@ describe("plan / apply / drift / verify machine commands", () => {
161
164
  expect(response.status).toBe("complete");
162
165
  expect(response.data.planId).toMatch(/^[0-9a-f]{12}$/);
163
166
  expect(response.data.requiresApproval).toBe(false);
167
+ expect(response.data.schemaAnalysis).toMatchObject({
168
+ parserMode: "static-ast",
169
+ semanticFingerprint: expect.stringMatching(/^[0-9a-f]{64}$/),
170
+ canonicalModel: expect.objectContaining({
171
+ name: "Todo",
172
+ fields: expect.arrayContaining([expect.objectContaining({ name: "id", type: "string" })]),
173
+ }),
174
+ warnings: [],
175
+ });
176
+ expect(response.data.diagnostics).toEqual([]);
164
177
  expect(response.data.operations).toEqual(
165
178
  expect.arrayContaining([
166
179
  expect.objectContaining({ path: "app/api/todo/route.ts", action: "create", ownership: "managed" }),
@@ -309,6 +322,55 @@ describe("plan / apply / drift / verify machine commands", () => {
309
322
  }
310
323
  );
311
324
 
325
+ it("preserves enum and literal semantics in UI, OpenAPI, and C# artifacts", async () => {
326
+ createNativeBackendFixture(tempDir, "csharp");
327
+ writeFile(
328
+ path.join(tempDir, "shared", "models", "learning-question.ts"),
329
+ `import { z } from 'zod/v4';
330
+ export const LearningQuestion = z.object({
331
+ id: z.string(),
332
+ kind: z.enum(['grammar', 'vocabulary']),
333
+ level: z.literal('5'),
334
+ createdAt: z.string().optional(),
335
+ updatedAt: z.string().optional(),
336
+ });
337
+ export type LearningQuestion = z.infer<typeof LearningQuestion>;
338
+ `
339
+ );
340
+ const restoreCodegenMocks = mockSuccessfulNativeCodegen();
341
+
342
+ try {
343
+ const applied = await runMachine(machineArgs("apply", "scaffold", "learning-question"));
344
+ expect(applied.response).toEqual(expect.objectContaining({ ok: true, status: "complete" }));
345
+
346
+ const form = fs.readFileSync(
347
+ path.join(tempDir, "app", "learning-question", "_components", "LearningQuestionForm.tsx"),
348
+ "utf-8"
349
+ );
350
+ expect(form).toContain('<select');
351
+ expect(form).toContain('<option value="grammar">grammar</option>');
352
+ expect(form).toContain('<option value="5">5</option>');
353
+
354
+ const openApi = JSON.parse(
355
+ fs.readFileSync(path.join(tempDir, "functions", "openapi", "learning-question.openapi.json"), "utf-8")
356
+ );
357
+ const properties = openApi.components.schemas.LearningQuestion.properties;
358
+ expect(properties.kind.enum).toEqual(["grammar", "vocabulary"]);
359
+ expect(properties.level.enum).toEqual(["5"]);
360
+
361
+ const csharp = fs.readFileSync(
362
+ path.join(tempDir, "functions", "generated", "csharp-models", "src", "SwallowKitBackendModels", "Model", "LearningQuestion.cs"),
363
+ "utf-8"
364
+ );
365
+ expect(csharp).toContain("public enum KindEnum");
366
+ expect(csharp).toContain("public enum LevelEnum");
367
+ expect(csharp).not.toContain("Dictionary<string, object> Kind");
368
+ expect(csharp).not.toContain("Dictionary<string, object> Level");
369
+ } finally {
370
+ restoreCodegenMocks();
371
+ }
372
+ });
373
+
312
374
  it("leaves the project untouched when the native toolchain validation fails on apply", async () => {
313
375
  createNativeBackendFixture(tempDir, "csharp");
314
376
  const spawnSyncMock = childProcess.spawnSync as unknown as any;
@@ -356,6 +418,96 @@ describe("plan / apply / drift / verify machine commands", () => {
356
418
  expect(fs.existsSync(path.join(tempDir, "app"))).toBe(false);
357
419
  });
358
420
 
421
+ it("blocks planning without writing artifacts when dynamic schema evaluation cannot start", async () => {
422
+ createProjectFixture(tempDir);
423
+ writeFile(
424
+ path.join(tempDir, "shared", "models", "computed.ts"),
425
+ `import { z } from 'zod/v4';
426
+ const field = z.string();
427
+ export const Computed = z.object({ id: field.transform(value => value) });
428
+ `
429
+ );
430
+ const unavailable = Object.assign(new Error("spawn EPERM"), { code: "EPERM" });
431
+ (childProcess.execFileSync as unknown as ReturnType<typeof jest.fn>).mockImplementation(() => { throw unavailable; });
432
+
433
+ const { response, exitCode } = await runMachine(machineArgs("plan", "scaffold", "computed", "--api-only"));
434
+
435
+ expect(exitCode).toBe(1);
436
+ expect(response).toMatchObject({
437
+ ok: false,
438
+ status: "blocked",
439
+ error: {
440
+ code: "schema-evaluation-unavailable",
441
+ details: expect.objectContaining({ command: process.execPath, reason: "EPERM" }),
442
+ },
443
+ });
444
+ expect(fs.existsSync(path.join(tempDir, "app"))).toBe(false);
445
+ expect(fs.existsSync(path.join(tempDir, ".swallowkit", "artifacts.json"))).toBe(false);
446
+ });
447
+
448
+ it("blocks apply when saved parser metadata differs from current analysis", async () => {
449
+ createProjectFixture(tempDir);
450
+ const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only"));
451
+ const planPath = path.join(tempDir, ".swallowkit", "state", "plans", `${plan.response.data.planId}.json`);
452
+ const savedPlan = JSON.parse(fs.readFileSync(planPath, "utf-8"));
453
+ savedPlan.schemaAnalysis.parserMode = "dynamic";
454
+ fs.writeFileSync(planPath, JSON.stringify(savedPlan, null, 2));
455
+
456
+ const { response, exitCode } = await runMachine(
457
+ machineArgs("apply", "scaffold", "--plan", plan.response.data.planId)
458
+ );
459
+
460
+ expect(exitCode).toBe(1);
461
+ expect(response).toMatchObject({ ok: false, status: "blocked", error: { code: "schema-analysis-mismatch" } });
462
+ expect(fs.existsSync(path.join(tempDir, "app"))).toBe(false);
463
+ expect(fs.existsSync(path.join(tempDir, ".swallowkit", "artifacts.json"))).toBe(false);
464
+ });
465
+
466
+ it("blocks apply without writes when dynamic evaluation becomes unavailable after planning", async () => {
467
+ createProjectFixture(tempDir);
468
+ writeFile(
469
+ path.join(tempDir, "shared", "models", "computed.ts"),
470
+ `import { z } from 'zod/v4';
471
+ const baseFields = { id: z.string(), kind: z.enum(['grammar', 'vocabulary']) };
472
+ export const Computed = z.object({ ...baseFields, level: z.literal('5') });
473
+ `
474
+ );
475
+ const plan = await runMachine(machineArgs("plan", "scaffold", "computed", "--api-only"));
476
+ expect(plan.response).toMatchObject({
477
+ ok: true,
478
+ data: { schemaAnalysis: { parserMode: "dynamic" } },
479
+ });
480
+
481
+ const unavailable = Object.assign(new Error("spawn EPERM"), { code: "EPERM" });
482
+ (childProcess.execFileSync as unknown as ReturnType<typeof jest.fn>).mockImplementation(() => { throw unavailable; });
483
+ const applied = await runMachine(machineArgs("apply", "scaffold", "--plan", plan.response.data.planId));
484
+
485
+ expect(applied.exitCode).toBe(1);
486
+ expect(applied.response).toMatchObject({
487
+ ok: false,
488
+ status: "blocked",
489
+ error: { code: "schema-evaluation-unavailable" },
490
+ });
491
+ expect(fs.existsSync(path.join(tempDir, "app"))).toBe(false);
492
+ expect(fs.existsSync(path.join(tempDir, ".swallowkit", "artifacts.json"))).toBe(false);
493
+ });
494
+
495
+ it("does not report schema drift for an unrelated exported response contract", async () => {
496
+ createProjectFixture(tempDir);
497
+ await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only"));
498
+ fs.appendFileSync(
499
+ path.join(tempDir, "shared", "models", "todo.ts"),
500
+ "\nexport const TodoRevealFeedback = z.object({ message: z.string() });\n"
501
+ );
502
+
503
+ const drift = await runMachine(machineArgs("inspect", "drift"));
504
+
505
+ expect(drift.response.ok).toBe(true);
506
+ expect(
507
+ drift.response.data.findings.filter((finding: { kind: string }) => finding.kind === "schema-drift")
508
+ ).toEqual([]);
509
+ });
510
+
359
511
  it("rejects a native backend plan when a nested model changes after planning", async () => {
360
512
  createNativeBackendFixture(tempDir, "csharp");
361
513
  writeFile(path.join(tempDir, "shared", "models", "member.ts"), createModelSource("Member"));
@@ -8,6 +8,7 @@ import * as path from "path";
8
8
  import { spawnSync } from "child_process";
9
9
  import { getBackendLanguage, getConnectorDefinition, getAuthConfig, getFullConfig, getNormalizedAuthConfig, ensureSwallowKitProject, normalizeAuthConfig, validateConfig } from "../../core/config";
10
10
  import { ModelInfo, parseModelFile, toKebabCase, toPascalCase, toCamelCase } from "../../core/scaffold/model-parser";
11
+ import { MachineCommandError } from "../../machine/errors";
11
12
  import {
12
13
  generateCSharpAzureFunctionsCRUD,
13
14
  generateCompactAzureFunctionsCRUD,
@@ -47,7 +48,7 @@ import { generateBFFCallFunctionWithAuth, generateBFFCallFunctionWithMultipleAut
47
48
  import {
48
49
  FileOperationSession,
49
50
  getActiveFileSession,
50
- hashFileIfExists,
51
+ SchemaAnalysisSnapshot,
51
52
  } from "../../core/operations/file-session";
52
53
  import { recordSessionOperations } from "../../core/project/artifacts";
53
54
 
@@ -58,6 +59,7 @@ interface ScaffoldOptions {
58
59
  functionsDir?: string; // Azure Functions のディレクトリ(デフォルト: "functions")
59
60
  apiDir?: string; // Next.js API routes のディレクトリ(デフォルト: "app/api")
60
61
  apiOnly?: boolean; // true の場合、UI を生成しない(デフォルト: false)
62
+ expectedSchemaAnalysis?: SchemaAnalysisSnapshot;
61
63
  }
62
64
 
63
65
  function getMachineAwareStdio(): "inherit" | "pipe" {
@@ -100,6 +102,31 @@ export async function scaffoldCommand(options: ScaffoldOptions) {
100
102
  // 2. Parse model file
101
103
  console.log("🔍 Parsing model file...");
102
104
  const modelInfo = await parseModelFile(modelPath);
105
+ const schemaAnalysis: SchemaAnalysisSnapshot = {
106
+ parserMode: modelInfo.parserMode,
107
+ semanticFingerprint: modelInfo.semanticFingerprint,
108
+ canonicalModel: {
109
+ name: modelInfo.name,
110
+ schemaName: modelInfo.schemaName,
111
+ fields: modelInfo.fields,
112
+ nestedSchemaRefs: modelInfo.nestedSchemaRefs,
113
+ partitionKey: modelInfo.partitionKey,
114
+ connectorConfig: modelInfo.connectorConfig,
115
+ authPolicy: modelInfo.authPolicy,
116
+ },
117
+ warnings: modelInfo.parserWarnings,
118
+ };
119
+ if (options.expectedSchemaAnalysis &&
120
+ (options.expectedSchemaAnalysis.parserMode !== schemaAnalysis.parserMode ||
121
+ options.expectedSchemaAnalysis.semanticFingerprint !== schemaAnalysis.semanticFingerprint)) {
122
+ throw new MachineCommandError(
123
+ "schema-analysis-mismatch",
124
+ "Schema analysis changed between plan and apply; no artifacts were written.",
125
+ { expected: options.expectedSchemaAnalysis, actual: schemaAnalysis, nextAction: "Re-run plan scaffold in the same environment, then apply the new plan." },
126
+ "blocked"
127
+ );
128
+ }
129
+ session.setSchemaAnalysis(schemaAnalysis);
103
130
  console.log(`✅ Model parsed: ${modelInfo.name} (${modelInfo.schemaName})`);
104
131
 
105
132
  const backendLanguage = getBackendLanguage();
@@ -199,7 +226,7 @@ export async function scaffoldCommand(options: ScaffoldOptions) {
199
226
 
200
227
  if (session.mode === "commit") {
201
228
  recordSessionOperations(session.operations, {
202
- schemaHash: hashFileIfExists(modelPath) ?? undefined,
229
+ schemaHash: modelInfo.semanticFingerprint,
203
230
  sourceModel: modelInfo.name,
204
231
  });
205
232
  await syncProjectManifest();
@@ -223,6 +250,7 @@ export async function scaffoldCommand(options: ScaffoldOptions) {
223
250
  );
224
251
  console.log(` ${options.apiOnly ? (backendLanguage === "typescript" ? "4" : "5") : (backendLanguage === "typescript" ? "6" : "7")}. Run '${getCommands(detectFromProject()).exec} swallowkit dev' to test the generated code`);
225
252
  } catch (error: any) {
253
+ if (error instanceof MachineCommandError) throw error;
226
254
  console.error("\n❌ Scaffold failed:", error.message);
227
255
  process.exit(1);
228
256
  }
@@ -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(effectiveOptions);
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,23 +85,22 @@ 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
- const bySchema = new Map<string, { schemaHash: string; sourceModel?: string; paths: string[] }>();
92
+ ): Promise<void> {
93
+ const byModel = new Map<string, ArtifactRecord[]>();
93
94
 
94
95
  for (const record of records) {
95
96
  if (!record.schemaHash || !record.sourceModel) continue;
96
- const key = record.sourceModel;
97
- const entry = bySchema.get(key) ?? { schemaHash: record.schemaHash, sourceModel: record.sourceModel, paths: [] };
98
- entry.schemaHash = record.schemaHash;
99
- entry.paths.push(record.path);
100
- bySchema.set(key, entry);
97
+ if (record.ownership === "extension-point" || record.ownership === "user-owned") continue;
98
+ const modelRecords = byModel.get(record.sourceModel) ?? [];
99
+ modelRecords.push(record);
100
+ byModel.set(record.sourceModel, modelRecords);
101
101
  }
102
102
 
103
- for (const [modelName, entry] of bySchema) {
103
+ for (const [modelName, modelRecords] of byModel) {
104
104
  // モデルファイルは shared/models/<kebab>.ts 規約
105
105
  const kebab = modelName
106
106
  .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
@@ -111,15 +111,20 @@ function detectSchemaDrift(
111
111
  if (currentSchemaHash === null) {
112
112
  continue; // モデルファイルが見つからない場合は manifest-drift 側で検出される
113
113
  }
114
- if (currentSchemaHash !== entry.schemaHash) {
114
+ const semanticSchemaHash = (await parseModelFile(modelPath)).semanticFingerprint;
115
+ const staleRecords = modelRecords.filter(
116
+ (record) => record.schemaHash !== semanticSchemaHash && record.schemaHash !== currentSchemaHash
117
+ );
118
+ if (staleRecords.length > 0) {
119
+ const staleSchemaHashes = [...new Set(staleRecords.map((record) => record.schemaHash!))].sort();
115
120
  findings.push({
116
121
  kind: "schema-drift",
117
122
  severity: "warning",
118
- message: `Model "${modelName}" changed after its artifacts were generated (${entry.paths.length} artifact(s) are stale).`,
123
+ message: `Model "${modelName}" changed after its artifacts were generated (${staleRecords.length} artifact(s) are stale).`,
119
124
  path: `shared/models/${kebab}.ts`,
120
125
  entity: modelName,
121
- expected: entry.schemaHash,
122
- actual: currentSchemaHash,
126
+ expected: staleSchemaHashes.join(", "),
127
+ actual: semanticSchemaHash,
123
128
  repairAction: `Run "swallowkit machine plan scaffold ${kebab}" then apply to regenerate artifacts for ${modelName}.`,
124
129
  });
125
130
  }
@@ -181,7 +186,7 @@ export async function detectDrift(projectRoot: string = process.cwd()): Promise<
181
186
  for (const record of ledger.artifacts) {
182
187
  detectArtifactDrift(record, projectRoot, findings);
183
188
  }
184
- detectSchemaDrift(ledger.artifacts, projectRoot, findings);
189
+ await detectSchemaDrift(ledger.artifacts, projectRoot, findings);
185
190
  detectGeneratorDrift(ledger.artifacts, findings);
186
191
  } else {
187
192
  findings.push({