coding-agent-skills 0.2.12 → 0.2.14
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 +41 -0
- package/README.md +8 -0
- package/ROADMAP.md +8 -4
- package/bin/coding-agent-skills +14 -0
- package/docs/adapters/README.md +38 -0
- package/docs/adapters/project-installation.md +25 -0
- package/docs/adapters/real-project-adoption.md +3 -2
- package/docs/architecture/README.md +4 -2
- package/docs/release/README.md +5 -3
- package/docs/release/npm-package.md +12 -2
- package/docs/safety/README.md +11 -1
- package/docs/testing/README.md +16 -0
- package/docs/usage/README.md +25 -5
- package/examples/command-policies/github-handoff.json +74 -0
- package/examples/command-policies/migration-review.json +70 -0
- package/examples/evidence-packs/github-handoff.json +67 -0
- package/examples/evidence-packs/migration-review.json +60 -0
- package/examples/manifests/github-handoff.json +14 -0
- package/examples/manifests/migration-review.json +14 -0
- package/examples/workflows/github-handoff.md +5 -0
- package/examples/workflows/migration-review.md +7 -0
- package/package.json +3 -1
- package/runs/skill-runs.md +32 -0
- package/schemas/project-adapter-installation.schema.json +4 -0
- package/schemas/project-adapter.schema.json +4 -0
- package/scripts/lib/github-handoff.mjs +446 -0
- package/scripts/lib/migration-review.mjs +641 -0
- package/scripts/lib/pack-rules.mjs +20 -2
- package/scripts/render-github-handoff.mjs +7 -0
- package/scripts/render-migration-review.mjs +8 -0
- package/scripts/test-pack.mjs +147 -1
- package/scripts/validate-pack.mjs +8 -2
- package/skills/github-handoff/SKILL.md +95 -0
- package/skills/github-handoff/adapter-interface.md +18 -0
- package/skills/github-handoff/agents/openai.yaml +3 -0
- package/skills/github-handoff/checklist.md +10 -0
- package/skills/github-handoff/evidence-template.md +16 -0
- package/skills/github-handoff/examples.md +19 -0
- package/skills/github-handoff/failure-modes.md +8 -0
- package/skills/migration-review/SKILL.md +87 -0
- package/skills/migration-review/adapter-interface.md +16 -0
- package/skills/migration-review/agents/openai.yaml +3 -0
- package/skills/migration-review/checklist.md +8 -0
- package/skills/migration-review/evidence-template.md +12 -0
- package/skills/migration-review/examples.md +20 -0
- package/skills/migration-review/failure-modes.md +5 -0
- package/tests/fixtures/github-handoff/adapter-project/.coding-agent/adapters/github-handoff-fixture/adapter.json +56 -0
- package/tests/fixtures/github-handoff/adapter-project/.coding-agent/skills.json +23 -0
- package/tests/fixtures/github-handoff/adapter-project/README.md +3 -0
- package/tests/fixtures/github-handoff/adapter-project/package.json +4 -0
- package/tests/fixtures/github-handoff/adapter-project/src/index.js +1 -0
- package/tests/fixtures/github-handoff/static-project/README.md +3 -0
- package/tests/fixtures/github-handoff/static-project/package.json +4 -0
- package/tests/fixtures/github-handoff/static-project/src/index.js +1 -0
- package/tests/fixtures/migration-review/adapter-project/.coding-agent/adapters/migration-review-fixture/adapter.json +56 -0
- package/tests/fixtures/migration-review/adapter-project/.coding-agent/skills.json +23 -0
- package/tests/fixtures/migration-review/adapter-project/README.md +3 -0
- package/tests/fixtures/migration-review/adapter-project/db/migrations/001_create_accounts.sql +3 -0
- package/tests/fixtures/migration-review/adapter-project/ignored/migrations/999_ignore.sql +1 -0
- package/tests/fixtures/migration-review/adapter-project/package.json +3 -0
- package/tests/fixtures/migration-review/static-project/README.md +3 -0
- package/tests/fixtures/migration-review/static-project/drizzle.config.ts +4 -0
- package/tests/fixtures/migration-review/static-project/package.json +7 -0
- package/tests/fixtures/migration-review/static-project/prisma/migrations/20260703010101_init/migration.sql +6 -0
- package/tests/fixtures/migration-review/static-project/prisma/schema.prisma +4 -0
- package/tests/fixtures/triggers/cases.json +26 -2
- package/tests/trigger/README.md +4 -0
- package/work-ledger.md +27 -5
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ADAPTER_MANIFEST_FILENAME,
|
|
8
|
+
readSafeJsonFile,
|
|
9
|
+
} from "./adapter-discovery.mjs";
|
|
10
|
+
import { PILOT_VERSION } from "./pack-rules.mjs";
|
|
11
|
+
import {
|
|
12
|
+
readProjectAdapterDeclaration,
|
|
13
|
+
validateProjectAdapters,
|
|
14
|
+
} from "./project-adapter-installation.mjs";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_CORE_ROOT = path.resolve(
|
|
17
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
18
|
+
"..",
|
|
19
|
+
"..",
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
const SKILL_ID = "migration-review";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_IGNORED_PATHS = [
|
|
25
|
+
".git",
|
|
26
|
+
".next",
|
|
27
|
+
"node_modules",
|
|
28
|
+
"dist",
|
|
29
|
+
"build",
|
|
30
|
+
"coverage",
|
|
31
|
+
"out",
|
|
32
|
+
"validation-output",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const TEXT_EXTENSIONS = new Set([
|
|
36
|
+
".cjs",
|
|
37
|
+
".cts",
|
|
38
|
+
".js",
|
|
39
|
+
".json",
|
|
40
|
+
".jsx",
|
|
41
|
+
".md",
|
|
42
|
+
".mjs",
|
|
43
|
+
".mts",
|
|
44
|
+
".prisma",
|
|
45
|
+
".sql",
|
|
46
|
+
".toml",
|
|
47
|
+
".ts",
|
|
48
|
+
".tsx",
|
|
49
|
+
".txt",
|
|
50
|
+
".yaml",
|
|
51
|
+
".yml",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const MIGRATION_CONFIG_BASENAMES = new Set([
|
|
55
|
+
"dbmate.yml",
|
|
56
|
+
"dbmate.yaml",
|
|
57
|
+
"drizzle.config.js",
|
|
58
|
+
"drizzle.config.mjs",
|
|
59
|
+
"drizzle.config.ts",
|
|
60
|
+
"knexfile.js",
|
|
61
|
+
"knexfile.ts",
|
|
62
|
+
"migrate.config.js",
|
|
63
|
+
"migrate.config.ts",
|
|
64
|
+
"schema.prisma",
|
|
65
|
+
"sequelize.config.js",
|
|
66
|
+
"supabase.toml",
|
|
67
|
+
"typeorm.config.js",
|
|
68
|
+
"typeorm.config.ts",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const REFUSED_BEHAVIOR = [
|
|
72
|
+
"no database connections",
|
|
73
|
+
"no migration apply/status/reset commands",
|
|
74
|
+
"no ORM generation",
|
|
75
|
+
"no target project builds",
|
|
76
|
+
"no target project tests",
|
|
77
|
+
"no package installs",
|
|
78
|
+
"no app-code execution",
|
|
79
|
+
"no deployments",
|
|
80
|
+
"no service or process mutation",
|
|
81
|
+
"no secret-file reads",
|
|
82
|
+
"no project writes",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const NOT_VERIFIED = [
|
|
86
|
+
"whether migrations have been applied",
|
|
87
|
+
"database schema currently deployed",
|
|
88
|
+
"rollback behavior",
|
|
89
|
+
"data backfill correctness",
|
|
90
|
+
"production safety or lock behavior",
|
|
91
|
+
"generated ORM clients",
|
|
92
|
+
"runtime database connectivity",
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
function inside(root, candidate) {
|
|
96
|
+
const relative = path.relative(root, candidate);
|
|
97
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function toPosix(relativePath) {
|
|
101
|
+
return relativePath.split(path.sep).join("/");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function safeRelativePath(candidate) {
|
|
105
|
+
return (
|
|
106
|
+
typeof candidate === "string" &&
|
|
107
|
+
candidate.length > 0 &&
|
|
108
|
+
!candidate.startsWith("/") &&
|
|
109
|
+
!candidate.split(/[\\/]+/).includes("..")
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function secretBearingPath(relativePath) {
|
|
114
|
+
const normalized = toPosix(relativePath);
|
|
115
|
+
const basename = path.posix.basename(normalized);
|
|
116
|
+
return (
|
|
117
|
+
basename === ".env" ||
|
|
118
|
+
basename.startsWith(".env.") ||
|
|
119
|
+
basename === ".npmrc" ||
|
|
120
|
+
/\.(?:pem|key|p12|pfx)$/i.test(basename) ||
|
|
121
|
+
/(?:^|\/)(?:secrets?|credentials?|private-key|service-role)(?:\/|$)/i.test(normalized)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ignoredBy(relativePath, ignoredPaths) {
|
|
126
|
+
const normalized = toPosix(relativePath);
|
|
127
|
+
return ignoredPaths.some((ignored) => {
|
|
128
|
+
const clean = toPosix(ignored).replace(/\/+$/g, "");
|
|
129
|
+
return normalized === clean || normalized.startsWith(`${clean}/`);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function gitSummary(projectRoot) {
|
|
134
|
+
const summary = {
|
|
135
|
+
root: null,
|
|
136
|
+
branchState: null,
|
|
137
|
+
hasUncommittedChanges: false,
|
|
138
|
+
warnings: [],
|
|
139
|
+
};
|
|
140
|
+
const revParse = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
141
|
+
cwd: projectRoot,
|
|
142
|
+
encoding: "utf8",
|
|
143
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
144
|
+
});
|
|
145
|
+
if (revParse.status === 0) summary.root = revParse.stdout.trim();
|
|
146
|
+
const status = spawnSync("git", ["status", "--short", "--branch"], {
|
|
147
|
+
cwd: projectRoot,
|
|
148
|
+
encoding: "utf8",
|
|
149
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
150
|
+
});
|
|
151
|
+
if (status.status !== 0) {
|
|
152
|
+
summary.warnings.push("git status unavailable");
|
|
153
|
+
return summary;
|
|
154
|
+
}
|
|
155
|
+
const lines = status.stdout.split(/\r?\n/).filter(Boolean);
|
|
156
|
+
summary.branchState = lines[0] ?? null;
|
|
157
|
+
summary.hasUncommittedChanges = lines.length > 1;
|
|
158
|
+
if (summary.hasUncommittedChanges) summary.warnings.push("working tree has local changes; filenames omitted");
|
|
159
|
+
if (/\[(?:ahead|behind|gone|diverged)[^\]]*\]/i.test(summary.branchState ?? "")) {
|
|
160
|
+
summary.warnings.push("branch state indicates remote divergence; revalidate after update");
|
|
161
|
+
}
|
|
162
|
+
return summary;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function adapterContext(projectRootInput, coreRoot) {
|
|
166
|
+
const loaded = readProjectAdapterDeclaration(projectRootInput);
|
|
167
|
+
if (!loaded.ok) {
|
|
168
|
+
if (loaded.codes.length === 1 && loaded.codes[0] === "missing-project-declaration") {
|
|
169
|
+
return {
|
|
170
|
+
ok: true,
|
|
171
|
+
present: false,
|
|
172
|
+
enabled: false,
|
|
173
|
+
projectRoot: path.resolve(projectRootInput),
|
|
174
|
+
mode: "none",
|
|
175
|
+
codes: [],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return { ok: false, present: false, enabled: false, status: "failed", codes: loaded.codes };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const validation = validateProjectAdapters(loaded.projectRoot, { coreRoot });
|
|
182
|
+
if (!validation.ok) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
present: true,
|
|
186
|
+
enabled: false,
|
|
187
|
+
status: "failed",
|
|
188
|
+
codes: validation.codes,
|
|
189
|
+
validation,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!validation.acceptedSkills.includes(SKILL_ID)) {
|
|
194
|
+
return {
|
|
195
|
+
ok: true,
|
|
196
|
+
present: true,
|
|
197
|
+
enabled: false,
|
|
198
|
+
projectRoot: loaded.projectRoot,
|
|
199
|
+
mode: "adapter-present-migration-review-not-enabled",
|
|
200
|
+
declarationPath: loaded.declarationPath,
|
|
201
|
+
declaration: loaded.declaration,
|
|
202
|
+
validation,
|
|
203
|
+
codes: [`${SKILL_ID}-not-enabled-by-adapter`],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const adapters = [];
|
|
208
|
+
const errors = [];
|
|
209
|
+
const container = path.resolve(loaded.projectRoot, loaded.declaration.adapterRoot);
|
|
210
|
+
if (!inside(loaded.projectRoot, container) || !fs.existsSync(container)) {
|
|
211
|
+
errors.push("adapter-root-not-found");
|
|
212
|
+
} else {
|
|
213
|
+
for (const declaration of loaded.declaration.adapters ?? []) {
|
|
214
|
+
if (!(declaration.skillIds ?? []).includes(SKILL_ID)) continue;
|
|
215
|
+
const manifestPath = path.join(container, declaration.id, ADAPTER_MANIFEST_FILENAME);
|
|
216
|
+
const record = readSafeJsonFile(manifestPath);
|
|
217
|
+
if (!record.value) {
|
|
218
|
+
errors.push(...record.codes);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
adapters.push({
|
|
222
|
+
declaration,
|
|
223
|
+
manifestPath: path.relative(loaded.projectRoot, manifestPath),
|
|
224
|
+
manifest: record.value,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (errors.length) {
|
|
229
|
+
return { ok: false, present: true, enabled: false, status: "failed", codes: errors, validation };
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
ok: true,
|
|
233
|
+
present: true,
|
|
234
|
+
enabled: true,
|
|
235
|
+
projectRoot: loaded.projectRoot,
|
|
236
|
+
mode: "adapter-enabled",
|
|
237
|
+
declarationPath: loaded.declarationPath,
|
|
238
|
+
declaration: loaded.declaration,
|
|
239
|
+
validation,
|
|
240
|
+
adapters,
|
|
241
|
+
codes: [],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function scopeFromAdapter(context) {
|
|
246
|
+
const safeReadPaths = new Set();
|
|
247
|
+
const ignoredPaths = new Set(DEFAULT_IGNORED_PATHS);
|
|
248
|
+
for (const adapter of context.adapters ?? []) {
|
|
249
|
+
for (const candidate of adapter.manifest.extensions?.safeReadPaths ?? []) {
|
|
250
|
+
if (safeRelativePath(candidate)) safeReadPaths.add(candidate);
|
|
251
|
+
}
|
|
252
|
+
for (const candidate of adapter.manifest.extensions?.ignoredPaths ?? []) {
|
|
253
|
+
if (safeRelativePath(candidate)) ignoredPaths.add(candidate);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
scopePaths: safeReadPaths.size ? [...safeReadPaths].sort() : ["."],
|
|
258
|
+
ignoredPaths: [...ignoredPaths].sort(),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function candidateFile(relativePath) {
|
|
263
|
+
const normalized = toPosix(relativePath);
|
|
264
|
+
const basename = path.posix.basename(normalized).toLowerCase();
|
|
265
|
+
return (
|
|
266
|
+
TEXT_EXTENSIONS.has(path.extname(basename)) ||
|
|
267
|
+
MIGRATION_CONFIG_BASENAMES.has(basename) ||
|
|
268
|
+
/(?:^|\/)(?:migrations?|schema|db|database|prisma|drizzle|supabase)(?:\/|$)/i.test(normalized)
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function collectFiles(projectRoot, scopePaths, ignoredPaths) {
|
|
273
|
+
const files = [];
|
|
274
|
+
const skipped = [];
|
|
275
|
+
for (const scopePath of scopePaths) {
|
|
276
|
+
if (!safeRelativePath(scopePath) && scopePath !== ".") {
|
|
277
|
+
skipped.push({ path: scopePath, reason: "unsafe scope path" });
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const absolute = path.resolve(projectRoot, scopePath);
|
|
281
|
+
if (!inside(projectRoot, absolute)) {
|
|
282
|
+
skipped.push({ path: scopePath, reason: "scope escapes project root" });
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (!fs.existsSync(absolute)) {
|
|
286
|
+
skipped.push({ path: scopePath, reason: "scope path not found" });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
walkPath(projectRoot, absolute, ignoredPaths, files, skipped);
|
|
290
|
+
}
|
|
291
|
+
return { files: [...new Set(files)].sort(), skipped };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function walkPath(projectRoot, absolute, ignoredPaths, files, skipped) {
|
|
295
|
+
const relative = toPosix(path.relative(projectRoot, absolute)) || ".";
|
|
296
|
+
if (relative !== "." && ignoredBy(relative, ignoredPaths)) {
|
|
297
|
+
skipped.push({ path: relative, reason: "ignored path" });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (relative !== "." && secretBearingPath(relative)) {
|
|
301
|
+
skipped.push({ path: relative, reason: "secret-bearing path excluded" });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const stat = fs.lstatSync(absolute);
|
|
305
|
+
if (stat.isSymbolicLink()) {
|
|
306
|
+
skipped.push({ path: relative, reason: "symbolic link skipped" });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (stat.isDirectory()) {
|
|
310
|
+
for (const entry of fs.readdirSync(absolute)) {
|
|
311
|
+
walkPath(projectRoot, path.join(absolute, entry), ignoredPaths, files, skipped);
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (!stat.isFile()) return;
|
|
316
|
+
if (stat.size > 512_000) {
|
|
317
|
+
skipped.push({ path: relative, reason: "file larger than bounded read limit" });
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (!candidateFile(relative)) {
|
|
321
|
+
skipped.push({ path: relative, reason: "not a migration-review candidate file" });
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
files.push(relative);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function detectMigrationFile(relativePath, text) {
|
|
328
|
+
const normalized = toPosix(relativePath);
|
|
329
|
+
const basename = path.posix.basename(normalized).toLowerCase();
|
|
330
|
+
const lower = text.toLowerCase();
|
|
331
|
+
const reasons = [];
|
|
332
|
+
if (/(?:^|\/)migrations?(?:\/|$)/i.test(normalized)) reasons.push("migration-directory");
|
|
333
|
+
if (basename === "migration.sql") reasons.push("canonical-migration-sql");
|
|
334
|
+
if (/^\d{8,}[_-]/.test(basename) || /^\d{4}[_-]\d{2}[_-]\d{2}/.test(basename)) {
|
|
335
|
+
reasons.push("timestamped-migration-file");
|
|
336
|
+
}
|
|
337
|
+
if (/\.(?:sql)$/i.test(basename) && /\b(?:create|alter|drop|rename|truncate)\b/i.test(text)) {
|
|
338
|
+
reasons.push("sql-ddl-content");
|
|
339
|
+
}
|
|
340
|
+
if (lower.includes("prisma migrate") || lower.includes("drizzle-kit") || lower.includes("knex migrate")) {
|
|
341
|
+
reasons.push("migration-tool-reference");
|
|
342
|
+
}
|
|
343
|
+
return reasons;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function detectSchemaFile(relativePath, text) {
|
|
347
|
+
const normalized = toPosix(relativePath);
|
|
348
|
+
const basename = path.posix.basename(normalized).toLowerCase();
|
|
349
|
+
const reasons = [];
|
|
350
|
+
if (basename === "schema.prisma") reasons.push("prisma-schema");
|
|
351
|
+
if (/drizzle\/schema|db\/schema|database\/schema|schema\.[jt]sx?$/i.test(normalized)) {
|
|
352
|
+
reasons.push("database-schema-path");
|
|
353
|
+
}
|
|
354
|
+
if (/\bmodel\s+[A-Z][A-Za-z0-9_]*\s*\{/m.test(text)) reasons.push("prisma-model-declaration");
|
|
355
|
+
if (/\b(?:pgTable|mysqlTable|sqliteTable)\s*\(/.test(text)) reasons.push("drizzle-table-declaration");
|
|
356
|
+
if (/\bCREATE\s+TABLE\b/i.test(text)) reasons.push("sql-table-declaration");
|
|
357
|
+
return reasons;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function detectConfigFile(relativePath, text) {
|
|
361
|
+
const normalized = toPosix(relativePath);
|
|
362
|
+
const basename = path.posix.basename(normalized).toLowerCase();
|
|
363
|
+
const reasons = [];
|
|
364
|
+
if (MIGRATION_CONFIG_BASENAMES.has(basename)) reasons.push("migration-config-filename");
|
|
365
|
+
if (
|
|
366
|
+
/(?:^|\/)(?:schema\.prisma|drizzle\.config\.[cm]?[jt]s|knexfile\.[jt]s|typeorm\.config\.[jt]s|sequelize\.config\.[jt]s|dbmate\.ya?ml|supabase\/config\.toml)$/i.test(
|
|
367
|
+
normalized,
|
|
368
|
+
)
|
|
369
|
+
) {
|
|
370
|
+
reasons.push("migration-tool-path");
|
|
371
|
+
}
|
|
372
|
+
if (reasons.length && /migrationsTable|migrationsDir|schema\s*:|out\s*:|migration/i.test(text)) {
|
|
373
|
+
reasons.push("migration-config-indicator");
|
|
374
|
+
}
|
|
375
|
+
return reasons;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function detectPackageScriptKeys(relativePath, text) {
|
|
379
|
+
if (path.posix.basename(toPosix(relativePath)) !== "package.json") return [];
|
|
380
|
+
try {
|
|
381
|
+
const parsed = JSON.parse(text);
|
|
382
|
+
const scripts = parsed && typeof parsed === "object" ? parsed.scripts : null;
|
|
383
|
+
if (!scripts || typeof scripts !== "object") return [];
|
|
384
|
+
return Object.entries(scripts)
|
|
385
|
+
.filter(([key, value]) => /migrat|prisma|drizzle|knex|dbmate|typeorm|sequelize/i.test(`${key} ${value}`))
|
|
386
|
+
.map(([key]) => ({ path: relativePath, key }));
|
|
387
|
+
} catch {
|
|
388
|
+
return [{ path: relativePath, key: "unparseable-package-json" }];
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function detectRiskIndicators(relativePath, text) {
|
|
393
|
+
const indicators = [];
|
|
394
|
+
const checks = [
|
|
395
|
+
["drop-table", /\bDROP\s+TABLE\b/i],
|
|
396
|
+
["drop-column", /\bALTER\s+TABLE\b[\s\S]{0,240}\bDROP\s+(?:COLUMN\s+)?/i],
|
|
397
|
+
["truncate-table", /\bTRUNCATE\s+(?:TABLE\s+)?/i],
|
|
398
|
+
["delete-without-where", /\bDELETE\s+FROM\b(?![\s\S]{0,160}\bWHERE\b)/i],
|
|
399
|
+
["rename-object", /\b(?:RENAME\s+(?:TABLE|COLUMN)|ALTER\s+TABLE\b[\s\S]{0,240}\bRENAME\b)/i],
|
|
400
|
+
["not-null-addition", /\bALTER\s+TABLE\b[\s\S]{0,240}\bSET\s+NOT\s+NULL\b/i],
|
|
401
|
+
["unique-index-or-constraint", /\b(?:CREATE\s+UNIQUE\s+INDEX|UNIQUE\s+CONSTRAINT|ADD\s+CONSTRAINT\b[\s\S]{0,160}\bUNIQUE)\b/i],
|
|
402
|
+
["raw-data-update", /\bUPDATE\s+[A-Za-z0-9_.\"]+\s+SET\b/i],
|
|
403
|
+
];
|
|
404
|
+
for (const [type, pattern] of checks) {
|
|
405
|
+
if (pattern.test(text)) indicators.push({ path: relativePath, type });
|
|
406
|
+
}
|
|
407
|
+
return indicators;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function scanMigrations(projectRoot, files) {
|
|
411
|
+
const migrationFiles = [];
|
|
412
|
+
const schemaFiles = [];
|
|
413
|
+
const configFiles = [];
|
|
414
|
+
const packageScriptKeys = [];
|
|
415
|
+
const riskIndicators = [];
|
|
416
|
+
const warnings = [];
|
|
417
|
+
|
|
418
|
+
for (const relative of files) {
|
|
419
|
+
let text = "";
|
|
420
|
+
try {
|
|
421
|
+
text = fs.readFileSync(path.join(projectRoot, relative), "utf8");
|
|
422
|
+
} catch {
|
|
423
|
+
warnings.push(`could not read ${relative}`);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const migrationReasons = detectMigrationFile(relative, text);
|
|
428
|
+
if (migrationReasons.length) {
|
|
429
|
+
migrationFiles.push({ path: relative, reasons: [...new Set(migrationReasons)].sort() });
|
|
430
|
+
}
|
|
431
|
+
const schemaReasons = detectSchemaFile(relative, text);
|
|
432
|
+
if (schemaReasons.length) {
|
|
433
|
+
schemaFiles.push({ path: relative, reasons: [...new Set(schemaReasons)].sort() });
|
|
434
|
+
}
|
|
435
|
+
const configReasons = detectConfigFile(relative, text);
|
|
436
|
+
if (configReasons.length) {
|
|
437
|
+
configFiles.push({ path: relative, reasons: [...new Set(configReasons)].sort() });
|
|
438
|
+
}
|
|
439
|
+
packageScriptKeys.push(...detectPackageScriptKeys(relative, text));
|
|
440
|
+
riskIndicators.push(...detectRiskIndicators(relative, text));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const byPath = (left, right) =>
|
|
444
|
+
`${left.path}:${left.type ?? left.key ?? ""}`.localeCompare(`${right.path}:${right.type ?? right.key ?? ""}`);
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
migrationFiles: migrationFiles.sort(byPath),
|
|
448
|
+
schemaFiles: schemaFiles.sort(byPath),
|
|
449
|
+
configFiles: configFiles.sort(byPath),
|
|
450
|
+
packageScriptKeys: packageScriptKeys.sort(byPath),
|
|
451
|
+
riskIndicators: riskIndicators.sort(byPath),
|
|
452
|
+
warnings,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function buildMigrationReviewReport(projectRootInput, options = {}) {
|
|
457
|
+
const coreRoot = options.coreRoot ?? DEFAULT_CORE_ROOT;
|
|
458
|
+
const context = adapterContext(projectRootInput, coreRoot);
|
|
459
|
+
if (!context.ok) {
|
|
460
|
+
return {
|
|
461
|
+
status: "failed",
|
|
462
|
+
coreVersion: PILOT_VERSION,
|
|
463
|
+
projectRoot: path.resolve(projectRootInput ?? "."),
|
|
464
|
+
adapter: context,
|
|
465
|
+
git: gitSummary(path.resolve(projectRootInput ?? ".")),
|
|
466
|
+
scopePaths: [],
|
|
467
|
+
ignoredPaths: DEFAULT_IGNORED_PATHS,
|
|
468
|
+
filesScanned: [],
|
|
469
|
+
migrationFiles: [],
|
|
470
|
+
schemaFiles: [],
|
|
471
|
+
configFiles: [],
|
|
472
|
+
packageScriptKeys: [],
|
|
473
|
+
riskIndicators: [],
|
|
474
|
+
skipped: [],
|
|
475
|
+
warnings: context.codes ?? [],
|
|
476
|
+
notVerified: NOT_VERIFIED,
|
|
477
|
+
refusedBehavior: REFUSED_BEHAVIOR,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const projectRoot = context.projectRoot;
|
|
482
|
+
const git = gitSummary(projectRoot);
|
|
483
|
+
if (context.present && !context.enabled) {
|
|
484
|
+
return {
|
|
485
|
+
status: "partial",
|
|
486
|
+
coreVersion: PILOT_VERSION,
|
|
487
|
+
projectRoot,
|
|
488
|
+
adapter: context,
|
|
489
|
+
git,
|
|
490
|
+
scopePaths: [],
|
|
491
|
+
ignoredPaths: DEFAULT_IGNORED_PATHS,
|
|
492
|
+
filesScanned: [],
|
|
493
|
+
migrationFiles: [],
|
|
494
|
+
schemaFiles: [],
|
|
495
|
+
configFiles: [],
|
|
496
|
+
packageScriptKeys: [],
|
|
497
|
+
riskIndicators: [],
|
|
498
|
+
skipped: [{ path: ".", reason: "project adapter is present but does not enable migration-review" }],
|
|
499
|
+
warnings: [
|
|
500
|
+
"migration-review is not enabled by the project adapter; target files were not read",
|
|
501
|
+
...git.warnings,
|
|
502
|
+
],
|
|
503
|
+
notVerified: NOT_VERIFIED,
|
|
504
|
+
refusedBehavior: REFUSED_BEHAVIOR,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const scope = context.enabled
|
|
509
|
+
? scopeFromAdapter(context)
|
|
510
|
+
: { scopePaths: ["."], ignoredPaths: DEFAULT_IGNORED_PATHS };
|
|
511
|
+
const collected = collectFiles(projectRoot, scope.scopePaths, scope.ignoredPaths);
|
|
512
|
+
const scanned = scanMigrations(projectRoot, collected.files);
|
|
513
|
+
return {
|
|
514
|
+
status: "complete",
|
|
515
|
+
coreVersion: PILOT_VERSION,
|
|
516
|
+
projectRoot,
|
|
517
|
+
adapter: context,
|
|
518
|
+
git,
|
|
519
|
+
scopePaths: scope.scopePaths,
|
|
520
|
+
ignoredPaths: scope.ignoredPaths,
|
|
521
|
+
filesScanned: collected.files,
|
|
522
|
+
migrationFiles: scanned.migrationFiles,
|
|
523
|
+
schemaFiles: scanned.schemaFiles,
|
|
524
|
+
configFiles: scanned.configFiles,
|
|
525
|
+
packageScriptKeys: scanned.packageScriptKeys,
|
|
526
|
+
riskIndicators: scanned.riskIndicators,
|
|
527
|
+
skipped: collected.skipped,
|
|
528
|
+
warnings: [
|
|
529
|
+
...git.warnings,
|
|
530
|
+
...scanned.warnings,
|
|
531
|
+
...(context.enabled ? ["migration-review used adapter-declared safe read paths only"] : []),
|
|
532
|
+
...(context.present ? [] : ["no project adapter declaration found; migration-review used generic bounded static scan"]),
|
|
533
|
+
],
|
|
534
|
+
notVerified: NOT_VERIFIED,
|
|
535
|
+
refusedBehavior: REFUSED_BEHAVIOR,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function renderRecords(records, formatter, empty = "- none found") {
|
|
540
|
+
if (!records.length) return [empty];
|
|
541
|
+
return records.slice(0, 80).map(formatter).concat(
|
|
542
|
+
records.length > 80 ? [`- ${records.length - 80} additional records omitted`] : [],
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function renderMigrationReviewReport(report) {
|
|
547
|
+
const lines = [
|
|
548
|
+
"# Migration Review Report",
|
|
549
|
+
"",
|
|
550
|
+
`Status: ${report.status}`,
|
|
551
|
+
`Core version: ${report.coreVersion}`,
|
|
552
|
+
`Project root: ${report.projectRoot}`,
|
|
553
|
+
"",
|
|
554
|
+
"## Git State",
|
|
555
|
+
`- Git root: ${report.git.root ?? "not detected"}`,
|
|
556
|
+
`- Branch state: ${report.git.branchState ?? "not detected"}`,
|
|
557
|
+
`- Local changes: ${report.git.hasUncommittedChanges ? "detected" : "not detected"}`,
|
|
558
|
+
"",
|
|
559
|
+
"## Adapter Scope",
|
|
560
|
+
`- Adapter present: ${report.adapter.present ? "yes" : "no"}`,
|
|
561
|
+
`- Migration-review enabled: ${report.adapter.enabled ? "yes" : "no"}`,
|
|
562
|
+
`- Mode: ${report.adapter.mode}`,
|
|
563
|
+
"",
|
|
564
|
+
"## Scope Paths",
|
|
565
|
+
...(report.scopePaths.length ? report.scopePaths.map((item) => `- ${item}`) : ["- none"]),
|
|
566
|
+
"",
|
|
567
|
+
"## Ignored Paths",
|
|
568
|
+
...report.ignoredPaths.map((item) => `- ${item}`),
|
|
569
|
+
"",
|
|
570
|
+
"## Summary",
|
|
571
|
+
`- Static files scanned: ${report.filesScanned.length}`,
|
|
572
|
+
`- Migration files: ${report.migrationFiles.length}`,
|
|
573
|
+
`- Schema files: ${report.schemaFiles.length}`,
|
|
574
|
+
`- Config files: ${report.configFiles.length}`,
|
|
575
|
+
`- Package script keys mentioning migrations: ${report.packageScriptKeys.length}`,
|
|
576
|
+
`- Risk indicators: ${report.riskIndicators.length}`,
|
|
577
|
+
`- Skipped items: ${report.skipped.length}`,
|
|
578
|
+
"",
|
|
579
|
+
"## Migration Files",
|
|
580
|
+
...renderRecords(report.migrationFiles, (record) => `- ${record.path}: ${record.reasons.join(", ")}`),
|
|
581
|
+
"",
|
|
582
|
+
"## Schema Files",
|
|
583
|
+
...renderRecords(report.schemaFiles, (record) => `- ${record.path}: ${record.reasons.join(", ")}`),
|
|
584
|
+
"",
|
|
585
|
+
"## Migration Config Files",
|
|
586
|
+
...renderRecords(report.configFiles, (record) => `- ${record.path}: ${record.reasons.join(", ")}`),
|
|
587
|
+
"",
|
|
588
|
+
"## Package Script Keys",
|
|
589
|
+
...renderRecords(
|
|
590
|
+
report.packageScriptKeys,
|
|
591
|
+
(record) => `- ${record.path}: ${record.key}`,
|
|
592
|
+
"- none found; command values are not printed",
|
|
593
|
+
),
|
|
594
|
+
"",
|
|
595
|
+
"## Risk Indicators",
|
|
596
|
+
...renderRecords(report.riskIndicators, (record) => `- ${record.path}: ${record.type}`),
|
|
597
|
+
"",
|
|
598
|
+
"## Skipped",
|
|
599
|
+
];
|
|
600
|
+
if (report.skipped.length) {
|
|
601
|
+
for (const skipped of report.skipped.slice(0, 40)) {
|
|
602
|
+
lines.push(`- ${skipped.path}: ${skipped.reason}`);
|
|
603
|
+
}
|
|
604
|
+
if (report.skipped.length > 40) lines.push(`- ${report.skipped.length - 40} additional skipped items omitted`);
|
|
605
|
+
} else {
|
|
606
|
+
lines.push("- none");
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
lines.push("", "## Not Verified");
|
|
610
|
+
for (const item of report.notVerified) lines.push(`- ${item}`);
|
|
611
|
+
lines.push("", "## Warnings");
|
|
612
|
+
if (report.warnings.length) {
|
|
613
|
+
for (const warning of report.warnings) lines.push(`- ${warning}`);
|
|
614
|
+
} else {
|
|
615
|
+
lines.push("- none");
|
|
616
|
+
}
|
|
617
|
+
lines.push("", "## Refused Behavior");
|
|
618
|
+
for (const item of report.refusedBehavior) lines.push(`- ${item}`);
|
|
619
|
+
lines.push(
|
|
620
|
+
"",
|
|
621
|
+
"No database connection, migration execution, ORM generation, target project build, test, runtime, deployment, package installation, secret-file read, or project write was performed.",
|
|
622
|
+
);
|
|
623
|
+
return lines.join("\n");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
export function migrationReviewCliResult(projectRootInput, options = {}) {
|
|
627
|
+
if (!projectRootInput) {
|
|
628
|
+
return {
|
|
629
|
+
exitCode: 2,
|
|
630
|
+
stream: "stderr",
|
|
631
|
+
lines: ["usage: node scripts/render-migration-review.mjs <project-root>"],
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
const report = buildMigrationReviewReport(projectRootInput, options);
|
|
635
|
+
return {
|
|
636
|
+
exitCode: report.status === "failed" ? 1 : 0,
|
|
637
|
+
stream: report.status === "failed" ? "stderr" : "stdout",
|
|
638
|
+
lines: renderMigrationReviewReport(report).split("\n"),
|
|
639
|
+
report,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
@@ -4,6 +4,8 @@ export const PILOT_SKILLS = [
|
|
|
4
4
|
"env-audit",
|
|
5
5
|
"secret-audit",
|
|
6
6
|
"api-contract-audit",
|
|
7
|
+
"migration-review",
|
|
8
|
+
"github-handoff",
|
|
7
9
|
"build-verify",
|
|
8
10
|
"git-preflight",
|
|
9
11
|
"runtime-truth",
|
|
@@ -19,6 +21,8 @@ export const AUDIT_ONLY_SKILLS = [
|
|
|
19
21
|
"env-audit",
|
|
20
22
|
"secret-audit",
|
|
21
23
|
"api-contract-audit",
|
|
24
|
+
"migration-review",
|
|
25
|
+
"github-handoff",
|
|
22
26
|
"git-preflight",
|
|
23
27
|
"runtime-truth",
|
|
24
28
|
"llm-drift-control",
|
|
@@ -398,6 +402,20 @@ export function classifyTrigger(prompt) {
|
|
|
398
402
|
) {
|
|
399
403
|
return "api-contract-audit";
|
|
400
404
|
}
|
|
405
|
+
if (
|
|
406
|
+
/\b(?:migration review|migration audit|database migrations?|schema migrations?|prisma migrations?|drizzle migrations?|supabase migrations?|migration drift|rollback review)\b/.test(
|
|
407
|
+
text,
|
|
408
|
+
)
|
|
409
|
+
) {
|
|
410
|
+
return "migration-review";
|
|
411
|
+
}
|
|
412
|
+
if (
|
|
413
|
+
/\b(?:github handoff|handoff report|handoff evidence|pull request handoff|pr handoff|changed files summary|git handoff|release handoff)\b/.test(
|
|
414
|
+
text,
|
|
415
|
+
)
|
|
416
|
+
) {
|
|
417
|
+
return "github-handoff";
|
|
418
|
+
}
|
|
401
419
|
if (
|
|
402
420
|
/\b(?:unfamiliar repository|canonical repository root|canonical repo|map the current packages|map this repository|identify its entry points|nested directory)\b/.test(
|
|
403
421
|
text,
|
|
@@ -539,7 +557,7 @@ function classifySegment(segment, options = {}) {
|
|
|
539
557
|
}
|
|
540
558
|
if (
|
|
541
559
|
executable === "node" &&
|
|
542
|
-
!/^node\s+(?:--check\b|--test\b|scripts\/(?:validate-pack|validate-maintainer-loop|validate-adapters|validate-project-adapters|check-adapter-upgrade|check-adapter-upgrade-chain|verify-evidence-bundle|render-evidence-archive-report|render-adapter-repo-map|render-route-trace|render-env-audit|render-secret-audit|render-api-contract-audit|test-pack)\.mjs\b)/.test(
|
|
560
|
+
!/^node\s+(?:--check\b|--test\b|scripts\/(?:validate-pack|validate-maintainer-loop|validate-adapters|validate-project-adapters|check-adapter-upgrade|check-adapter-upgrade-chain|verify-evidence-bundle|render-evidence-archive-report|render-adapter-repo-map|render-route-trace|render-env-audit|render-secret-audit|render-api-contract-audit|render-migration-review|render-github-handoff|test-pack)\.mjs\b)/.test(
|
|
543
561
|
segment,
|
|
544
562
|
)
|
|
545
563
|
) {
|
|
@@ -549,7 +567,7 @@ function classifySegment(segment, options = {}) {
|
|
|
549
567
|
["coding-agent-skills", "bin/coding-agent-skills", "./bin/coding-agent-skills"].includes(
|
|
550
568
|
executable,
|
|
551
569
|
) &&
|
|
552
|
-
!/^(?:\.\/)?(?:bin\/)?coding-agent-skills\s+(?:validate-pack|validate-project\s+\S+|repo-map\s+\S+|route-trace\s+\S+|env-audit\s+\S+|secret-audit\s+\S+|api-contract-audit\s+\S+|validate-adapters\s+\S+|help|--help|-h)\s*$/.test(
|
|
570
|
+
!/^(?:\.\/)?(?:bin\/)?coding-agent-skills\s+(?:validate-pack|validate-project\s+\S+|repo-map\s+\S+|route-trace\s+\S+|env-audit\s+\S+|secret-audit\s+\S+|api-contract-audit\s+\S+|migration-review\s+\S+|github-handoff\s+\S+|validate-adapters\s+\S+|help|--help|-h)\s*$/.test(
|
|
553
571
|
segment,
|
|
554
572
|
)
|
|
555
573
|
) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { githubHandoffCliResult } from "./lib/github-handoff.mjs";
|
|
3
|
+
|
|
4
|
+
const projectRoot = process.argv[2];
|
|
5
|
+
const result = githubHandoffCliResult(projectRoot);
|
|
6
|
+
process.stdout.write(`${result.lines.join("\n")}\n`);
|
|
7
|
+
process.exitCode = result.exitCode;
|