coding-agent-skills 0.2.11 → 0.2.13
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 +39 -0
- package/docs/adapters/project-installation.md +25 -0
- package/docs/adapters/real-project-adoption.md +2 -2
- package/docs/architecture/README.md +2 -0
- package/docs/release/README.md +4 -3
- package/docs/release/npm-package.md +11 -2
- package/docs/safety/README.md +11 -1
- package/docs/testing/README.md +15 -0
- package/docs/usage/README.md +24 -5
- package/examples/command-policies/api-contract-audit.json +70 -0
- package/examples/command-policies/migration-review.json +70 -0
- package/examples/evidence-packs/api-contract-audit.json +60 -0
- package/examples/evidence-packs/migration-review.json +60 -0
- package/examples/manifests/api-contract-audit.json +14 -0
- package/examples/manifests/migration-review.json +14 -0
- package/examples/workflows/api-contract-audit.md +8 -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/api-contract-audit.mjs +651 -0
- package/scripts/lib/migration-review.mjs +641 -0
- package/scripts/lib/pack-rules.mjs +20 -2
- package/scripts/render-api-contract-audit.mjs +8 -0
- package/scripts/render-migration-review.mjs +8 -0
- package/scripts/test-pack.mjs +120 -1
- package/scripts/validate-pack.mjs +8 -2
- package/skills/api-contract-audit/SKILL.md +85 -0
- package/skills/api-contract-audit/adapter-interface.md +16 -0
- package/skills/api-contract-audit/agents/openai.yaml +4 -0
- package/skills/api-contract-audit/checklist.md +7 -0
- package/skills/api-contract-audit/evidence-template.md +13 -0
- package/skills/api-contract-audit/examples.md +20 -0
- package/skills/api-contract-audit/failure-modes.md +5 -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/api-contract-audit/adapter-project/.coding-agent/adapters/api-contract-audit-fixture/adapter.json +53 -0
- package/tests/fixtures/api-contract-audit/adapter-project/.coding-agent/skills.json +23 -0
- package/tests/fixtures/api-contract-audit/adapter-project/README.md +3 -0
- package/tests/fixtures/api-contract-audit/adapter-project/package.json +4 -0
- package/tests/fixtures/api-contract-audit/adapter-project/src/routes.ts +1 -0
- package/tests/fixtures/api-contract-audit/static-project/README.md +3 -0
- package/tests/fixtures/api-contract-audit/static-project/app/api/users/route.ts +7 -0
- package/tests/fixtures/api-contract-audit/static-project/docs/openapi.yaml +10 -0
- package/tests/fixtures/api-contract-audit/static-project/package.json +4 -0
- package/tests/fixtures/api-contract-audit/static-project/schemas/user.schema.ts +4 -0
- package/tests/fixtures/api-contract-audit/static-project/src/client.ts +3 -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 +25 -1
- package/tests/trigger/README.md +4 -0
- package/work-ledger.md +31 -7
|
@@ -0,0 +1,651 @@
|
|
|
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, redactSensitiveText } 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 DEFAULT_IGNORED_PATHS = [
|
|
23
|
+
".git",
|
|
24
|
+
".next",
|
|
25
|
+
"node_modules",
|
|
26
|
+
"dist",
|
|
27
|
+
"build",
|
|
28
|
+
"coverage",
|
|
29
|
+
"out",
|
|
30
|
+
"validation-output",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const TEXT_EXTENSIONS = new Set([
|
|
34
|
+
".cjs",
|
|
35
|
+
".cts",
|
|
36
|
+
".gql",
|
|
37
|
+
".graphql",
|
|
38
|
+
".js",
|
|
39
|
+
".json",
|
|
40
|
+
".jsx",
|
|
41
|
+
".md",
|
|
42
|
+
".mjs",
|
|
43
|
+
".mts",
|
|
44
|
+
".ts",
|
|
45
|
+
".tsx",
|
|
46
|
+
".txt",
|
|
47
|
+
".yaml",
|
|
48
|
+
".yml",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"];
|
|
52
|
+
|
|
53
|
+
const REFUSED_BEHAVIOR = [
|
|
54
|
+
"no target project builds",
|
|
55
|
+
"no target project tests",
|
|
56
|
+
"no app-code execution",
|
|
57
|
+
"no URL probing",
|
|
58
|
+
"no API calls",
|
|
59
|
+
"no package installs",
|
|
60
|
+
"no schema generation",
|
|
61
|
+
"no client generation",
|
|
62
|
+
"no deployments",
|
|
63
|
+
"no migrations",
|
|
64
|
+
"no secret-file reads",
|
|
65
|
+
"no project writes",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const NOT_VERIFIED = [
|
|
69
|
+
"runtime route registration",
|
|
70
|
+
"deployed API behavior",
|
|
71
|
+
"database-backed schema behavior",
|
|
72
|
+
"authentication and authorization behavior",
|
|
73
|
+
"generated clients not present as static files",
|
|
74
|
+
"whether static contract and implementation are semantically equivalent",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
function inside(root, candidate) {
|
|
78
|
+
const relative = path.relative(root, candidate);
|
|
79
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toPosix(relativePath) {
|
|
83
|
+
return relativePath.split(path.sep).join("/");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function safeRelativePath(candidate) {
|
|
87
|
+
return (
|
|
88
|
+
typeof candidate === "string" &&
|
|
89
|
+
candidate.length > 0 &&
|
|
90
|
+
!candidate.startsWith("/") &&
|
|
91
|
+
!candidate.split(/[\\/]+/).includes("..")
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function secretBearingPath(relativePath) {
|
|
96
|
+
const normalized = toPosix(relativePath);
|
|
97
|
+
const basename = path.posix.basename(normalized);
|
|
98
|
+
return (
|
|
99
|
+
basename === ".env" ||
|
|
100
|
+
basename.startsWith(".env.") ||
|
|
101
|
+
basename === ".npmrc" ||
|
|
102
|
+
/\.(?:pem|key|p12|pfx)$/i.test(basename) ||
|
|
103
|
+
/(?:^|\/)(?:secrets?|credentials?|private-key)(?:\/|$)/i.test(normalized)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ignoredBy(relativePath, ignoredPaths) {
|
|
108
|
+
const normalized = toPosix(relativePath);
|
|
109
|
+
return ignoredPaths.some((ignored) => {
|
|
110
|
+
const clean = toPosix(ignored).replace(/\/+$/g, "");
|
|
111
|
+
return normalized === clean || normalized.startsWith(`${clean}/`);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function gitSummary(projectRoot) {
|
|
116
|
+
const summary = {
|
|
117
|
+
root: null,
|
|
118
|
+
branchState: null,
|
|
119
|
+
hasUncommittedChanges: false,
|
|
120
|
+
warnings: [],
|
|
121
|
+
};
|
|
122
|
+
const revParse = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
123
|
+
cwd: projectRoot,
|
|
124
|
+
encoding: "utf8",
|
|
125
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
126
|
+
});
|
|
127
|
+
if (revParse.status === 0) summary.root = revParse.stdout.trim();
|
|
128
|
+
const status = spawnSync("git", ["status", "--short", "--branch"], {
|
|
129
|
+
cwd: projectRoot,
|
|
130
|
+
encoding: "utf8",
|
|
131
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
132
|
+
});
|
|
133
|
+
if (status.status !== 0) {
|
|
134
|
+
summary.warnings.push("git status unavailable");
|
|
135
|
+
return summary;
|
|
136
|
+
}
|
|
137
|
+
const lines = status.stdout.split(/\r?\n/).filter(Boolean);
|
|
138
|
+
summary.branchState = lines[0] ?? null;
|
|
139
|
+
summary.hasUncommittedChanges = lines.length > 1;
|
|
140
|
+
if (summary.hasUncommittedChanges) summary.warnings.push("working tree has local changes; filenames omitted");
|
|
141
|
+
if (/\[(?:ahead|behind|gone|diverged)[^\]]*\]/i.test(summary.branchState ?? "")) {
|
|
142
|
+
summary.warnings.push("branch state indicates remote divergence; revalidate after update");
|
|
143
|
+
}
|
|
144
|
+
return summary;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function adapterContext(projectRootInput, coreRoot) {
|
|
148
|
+
const loaded = readProjectAdapterDeclaration(projectRootInput);
|
|
149
|
+
if (!loaded.ok) {
|
|
150
|
+
if (loaded.codes.length === 1 && loaded.codes[0] === "missing-project-declaration") {
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
present: false,
|
|
154
|
+
enabled: false,
|
|
155
|
+
projectRoot: path.resolve(projectRootInput),
|
|
156
|
+
mode: "none",
|
|
157
|
+
codes: [],
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return { ok: false, present: false, enabled: false, status: "failed", codes: loaded.codes };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const validation = validateProjectAdapters(loaded.projectRoot, { coreRoot });
|
|
164
|
+
if (!validation.ok) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
present: true,
|
|
168
|
+
enabled: false,
|
|
169
|
+
status: "failed",
|
|
170
|
+
codes: validation.codes,
|
|
171
|
+
validation,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!validation.acceptedSkills.includes("api-contract-audit")) {
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
present: true,
|
|
179
|
+
enabled: false,
|
|
180
|
+
projectRoot: loaded.projectRoot,
|
|
181
|
+
mode: "adapter-present-api-contract-audit-not-enabled",
|
|
182
|
+
declarationPath: loaded.declarationPath,
|
|
183
|
+
declaration: loaded.declaration,
|
|
184
|
+
validation,
|
|
185
|
+
codes: ["api-contract-audit-not-enabled-by-adapter"],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const adapters = [];
|
|
190
|
+
const errors = [];
|
|
191
|
+
const container = path.resolve(loaded.projectRoot, loaded.declaration.adapterRoot);
|
|
192
|
+
if (!inside(loaded.projectRoot, container) || !fs.existsSync(container)) {
|
|
193
|
+
errors.push("adapter-root-not-found");
|
|
194
|
+
} else {
|
|
195
|
+
for (const declaration of loaded.declaration.adapters ?? []) {
|
|
196
|
+
if (!(declaration.skillIds ?? []).includes("api-contract-audit")) continue;
|
|
197
|
+
const manifestPath = path.join(container, declaration.id, ADAPTER_MANIFEST_FILENAME);
|
|
198
|
+
const record = readSafeJsonFile(manifestPath);
|
|
199
|
+
if (!record.value) {
|
|
200
|
+
errors.push(...record.codes);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
adapters.push({
|
|
204
|
+
declaration,
|
|
205
|
+
manifestPath: path.relative(loaded.projectRoot, manifestPath),
|
|
206
|
+
manifest: record.value,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (errors.length) {
|
|
211
|
+
return { ok: false, present: true, enabled: false, status: "failed", codes: errors, validation };
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
ok: true,
|
|
215
|
+
present: true,
|
|
216
|
+
enabled: true,
|
|
217
|
+
projectRoot: loaded.projectRoot,
|
|
218
|
+
mode: "adapter-enabled",
|
|
219
|
+
declarationPath: loaded.declarationPath,
|
|
220
|
+
declaration: loaded.declaration,
|
|
221
|
+
validation,
|
|
222
|
+
adapters,
|
|
223
|
+
codes: [],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function scopeFromAdapter(context) {
|
|
228
|
+
const safeReadPaths = new Set();
|
|
229
|
+
const ignoredPaths = new Set(DEFAULT_IGNORED_PATHS);
|
|
230
|
+
for (const adapter of context.adapters ?? []) {
|
|
231
|
+
for (const candidate of adapter.manifest.extensions?.safeReadPaths ?? []) {
|
|
232
|
+
if (safeRelativePath(candidate)) safeReadPaths.add(candidate);
|
|
233
|
+
}
|
|
234
|
+
for (const candidate of adapter.manifest.extensions?.ignoredPaths ?? []) {
|
|
235
|
+
if (safeRelativePath(candidate)) ignoredPaths.add(candidate);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
scopePaths: safeReadPaths.size ? [...safeReadPaths].sort() : ["."],
|
|
240
|
+
ignoredPaths: [...ignoredPaths].sort(),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function candidateFile(relativePath) {
|
|
245
|
+
return TEXT_EXTENSIONS.has(path.extname(path.posix.basename(toPosix(relativePath))));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function collectFiles(projectRoot, scopePaths, ignoredPaths) {
|
|
249
|
+
const files = [];
|
|
250
|
+
const skipped = [];
|
|
251
|
+
for (const scopePath of scopePaths) {
|
|
252
|
+
if (!safeRelativePath(scopePath) && scopePath !== ".") {
|
|
253
|
+
skipped.push({ path: scopePath, reason: "unsafe scope path" });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const absolute = path.resolve(projectRoot, scopePath);
|
|
257
|
+
if (!inside(projectRoot, absolute)) {
|
|
258
|
+
skipped.push({ path: scopePath, reason: "scope escapes project root" });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (!fs.existsSync(absolute)) {
|
|
262
|
+
skipped.push({ path: scopePath, reason: "scope path not found" });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
walkPath(projectRoot, absolute, ignoredPaths, files, skipped);
|
|
266
|
+
}
|
|
267
|
+
return { files: [...new Set(files)].sort(), skipped };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function walkPath(projectRoot, absolute, ignoredPaths, files, skipped) {
|
|
271
|
+
const relative = toPosix(path.relative(projectRoot, absolute)) || ".";
|
|
272
|
+
if (relative !== "." && ignoredBy(relative, ignoredPaths)) {
|
|
273
|
+
skipped.push({ path: relative, reason: "ignored path" });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (relative !== "." && secretBearingPath(relative)) {
|
|
277
|
+
skipped.push({ path: relative, reason: "secret-bearing path excluded" });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const stat = fs.lstatSync(absolute);
|
|
281
|
+
if (stat.isSymbolicLink()) {
|
|
282
|
+
skipped.push({ path: relative, reason: "symbolic link skipped" });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (stat.isDirectory()) {
|
|
286
|
+
for (const entry of fs.readdirSync(absolute)) {
|
|
287
|
+
walkPath(projectRoot, path.join(absolute, entry), ignoredPaths, files, skipped);
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (!stat.isFile()) return;
|
|
292
|
+
if (stat.size > 512_000) {
|
|
293
|
+
skipped.push({ path: relative, reason: "file larger than bounded read limit" });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (!candidateFile(relative)) {
|
|
297
|
+
skipped.push({ path: relative, reason: "not an api-contract-audit candidate file" });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
files.push(relative);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function routeFromApiFile(relativePath) {
|
|
304
|
+
const normalized = toPosix(relativePath);
|
|
305
|
+
let route = null;
|
|
306
|
+
if (/^app\/api\/.+\/route\.[jt]sx?$/.test(normalized)) {
|
|
307
|
+
route = normalized
|
|
308
|
+
.replace(/^app\/api\//, "/api/")
|
|
309
|
+
.replace(/\/route\.[jt]sx?$/, "")
|
|
310
|
+
.replace(/\[([^\]]+)\]/g, ":$1");
|
|
311
|
+
} else if (/^pages\/api\/.+\.[jt]sx?$/.test(normalized)) {
|
|
312
|
+
route = normalized
|
|
313
|
+
.replace(/^pages\/api\//, "/api/")
|
|
314
|
+
.replace(/\.[jt]sx?$/, "")
|
|
315
|
+
.replace(/\/index$/, "")
|
|
316
|
+
.replace(/\[([^\]]+)\]/g, ":$1");
|
|
317
|
+
}
|
|
318
|
+
return route;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function detectContractFiles(relativePath, text) {
|
|
322
|
+
const normalized = toPosix(relativePath);
|
|
323
|
+
const basename = path.posix.basename(normalized).toLowerCase();
|
|
324
|
+
const lower = text.toLowerCase();
|
|
325
|
+
const reasons = [];
|
|
326
|
+
if (/openapi|swagger/.test(basename)) reasons.push("openapi-or-swagger-filename");
|
|
327
|
+
if (/api[-_.]?contract|contract[-_.]?api/.test(basename)) reasons.push("contract-filename");
|
|
328
|
+
if (/"openapi"\s*:|"swagger"\s*:/.test(text)) reasons.push("openapi-json-indicator");
|
|
329
|
+
if (/^\s*openapi\s*:/im.test(text) || /^\s*swagger\s*:/im.test(text)) {
|
|
330
|
+
reasons.push("openapi-yaml-indicator");
|
|
331
|
+
}
|
|
332
|
+
if (/^\s*\/[A-Za-z0-9_/{:[\].-]+:\s*$/m.test(text) && /(?:get|post|put|patch|delete)\s*:/i.test(text)) {
|
|
333
|
+
reasons.push("path-method-map");
|
|
334
|
+
}
|
|
335
|
+
if (lower.includes("graphql") && /\.(?:gql|graphql|md)$/i.test(normalized)) {
|
|
336
|
+
reasons.push("graphql-contract-indicator");
|
|
337
|
+
}
|
|
338
|
+
return reasons;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function detectSchemaFiles(relativePath, text) {
|
|
342
|
+
const normalized = toPosix(relativePath);
|
|
343
|
+
const basename = path.posix.basename(normalized).toLowerCase();
|
|
344
|
+
const reasons = [];
|
|
345
|
+
if (/(schema|schemas|types|dto|contract|model)/i.test(normalized)) {
|
|
346
|
+
reasons.push("schema-or-type-path");
|
|
347
|
+
}
|
|
348
|
+
if (/\bz\.object\s*\(|\bJoi\.object\s*\(|\byup\.object\s*\(/.test(text)) {
|
|
349
|
+
reasons.push("runtime-schema-declaration");
|
|
350
|
+
}
|
|
351
|
+
if (/^\s*(?:export\s+)?(?:interface|type)\s+[A-Z][A-Za-z0-9_]+/m.test(text)) {
|
|
352
|
+
reasons.push("typescript-type-declaration");
|
|
353
|
+
}
|
|
354
|
+
if (/"\$schema"\s*:/.test(text) || /"type"\s*:\s*"object"/.test(text)) {
|
|
355
|
+
reasons.push("json-schema-indicator");
|
|
356
|
+
}
|
|
357
|
+
if (basename.endsWith(".graphql") || basename.endsWith(".gql")) {
|
|
358
|
+
reasons.push("graphql-schema-file");
|
|
359
|
+
}
|
|
360
|
+
return reasons;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function detectEndpoints(relativePath, text) {
|
|
364
|
+
const findings = [];
|
|
365
|
+
const route = routeFromApiFile(relativePath);
|
|
366
|
+
if (route) {
|
|
367
|
+
const methodMatches = [...text.matchAll(/\bexport\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\b/g)];
|
|
368
|
+
const methods = methodMatches.length ? methodMatches.map((match) => match[1]) : ["UNKNOWN"];
|
|
369
|
+
for (const method of methods) {
|
|
370
|
+
findings.push({ path: relativePath, method, route, source: "file-route" });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const methodPattern = /\b(?:app|router|server|api)\.(get|post|put|patch|delete|options|head)\s*\(\s*["'`]([^"'`]+)["'`]/gi;
|
|
375
|
+
for (const match of text.matchAll(methodPattern)) {
|
|
376
|
+
findings.push({
|
|
377
|
+
path: relativePath,
|
|
378
|
+
method: match[1].toUpperCase(),
|
|
379
|
+
route: match[2],
|
|
380
|
+
source: "handler-declaration",
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const openApiPattern = /^\s*["']?(\/[A-Za-z0-9_/{:[\].-]+)["']?\s*:\s*(?:\{)?\s*$/gm;
|
|
385
|
+
for (const match of text.matchAll(openApiPattern)) {
|
|
386
|
+
const near = text.slice(match.index, match.index + 320);
|
|
387
|
+
const method = HTTP_METHODS.find((candidate) => new RegExp(`\\b${candidate.toLowerCase()}\\s*:`, "i").test(near));
|
|
388
|
+
if (method) {
|
|
389
|
+
findings.push({
|
|
390
|
+
path: relativePath,
|
|
391
|
+
method,
|
|
392
|
+
route: match[1],
|
|
393
|
+
source: "contract-path",
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return findings;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function detectClientCalls(relativePath, text) {
|
|
402
|
+
const calls = [];
|
|
403
|
+
const fetchPattern = /\bfetch\s*\(\s*["'`]([^"'`]+)["'`]/g;
|
|
404
|
+
for (const match of text.matchAll(fetchPattern)) {
|
|
405
|
+
if (match[1].startsWith("/api/") || /^https?:\/\//.test(match[1])) {
|
|
406
|
+
calls.push({ path: relativePath, target: redactSensitiveText(match[1]), source: "fetch" });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const axiosPattern = /\baxios\.(get|post|put|patch|delete)\s*\(\s*["'`]([^"'`]+)["'`]/gi;
|
|
410
|
+
for (const match of text.matchAll(axiosPattern)) {
|
|
411
|
+
if (match[2].startsWith("/api/") || /^https?:\/\//.test(match[2])) {
|
|
412
|
+
calls.push({
|
|
413
|
+
path: relativePath,
|
|
414
|
+
method: match[1].toUpperCase(),
|
|
415
|
+
target: redactSensitiveText(match[2]),
|
|
416
|
+
source: "axios",
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return calls;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function scanContracts(projectRoot, files) {
|
|
424
|
+
const contractFiles = [];
|
|
425
|
+
const endpointDeclarations = [];
|
|
426
|
+
const clientCalls = [];
|
|
427
|
+
const schemaFiles = [];
|
|
428
|
+
const warnings = [];
|
|
429
|
+
|
|
430
|
+
for (const relative of files) {
|
|
431
|
+
let text = "";
|
|
432
|
+
try {
|
|
433
|
+
text = fs.readFileSync(path.join(projectRoot, relative), "utf8");
|
|
434
|
+
} catch {
|
|
435
|
+
warnings.push(`could not read ${relative}`);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const contractReasons = detectContractFiles(relative, text);
|
|
440
|
+
if (contractReasons.length) {
|
|
441
|
+
contractFiles.push({ path: relative, reasons: [...new Set(contractReasons)].sort() });
|
|
442
|
+
}
|
|
443
|
+
endpointDeclarations.push(...detectEndpoints(relative, text));
|
|
444
|
+
clientCalls.push(...detectClientCalls(relative, text));
|
|
445
|
+
const schemaReasons = detectSchemaFiles(relative, text);
|
|
446
|
+
if (schemaReasons.length) {
|
|
447
|
+
schemaFiles.push({ path: relative, reasons: [...new Set(schemaReasons)].sort() });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const sortByPath = (left, right) =>
|
|
452
|
+
`${left.path}:${left.route ?? left.target ?? ""}`.localeCompare(
|
|
453
|
+
`${right.path}:${right.route ?? right.target ?? ""}`,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
contractFiles: contractFiles.sort(sortByPath),
|
|
458
|
+
endpointDeclarations: endpointDeclarations.sort(sortByPath),
|
|
459
|
+
clientCalls: clientCalls.sort(sortByPath),
|
|
460
|
+
schemaFiles: schemaFiles.sort(sortByPath),
|
|
461
|
+
warnings,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function buildApiContractAuditReport(projectRootInput, options = {}) {
|
|
466
|
+
const coreRoot = options.coreRoot ?? DEFAULT_CORE_ROOT;
|
|
467
|
+
const context = adapterContext(projectRootInput, coreRoot);
|
|
468
|
+
if (!context.ok) {
|
|
469
|
+
return {
|
|
470
|
+
status: "failed",
|
|
471
|
+
coreVersion: PILOT_VERSION,
|
|
472
|
+
projectRoot: path.resolve(projectRootInput ?? "."),
|
|
473
|
+
adapter: context,
|
|
474
|
+
git: gitSummary(path.resolve(projectRootInput ?? ".")),
|
|
475
|
+
scopePaths: [],
|
|
476
|
+
ignoredPaths: DEFAULT_IGNORED_PATHS,
|
|
477
|
+
filesScanned: [],
|
|
478
|
+
contractFiles: [],
|
|
479
|
+
endpointDeclarations: [],
|
|
480
|
+
clientCalls: [],
|
|
481
|
+
schemaFiles: [],
|
|
482
|
+
skipped: [],
|
|
483
|
+
warnings: context.codes ?? [],
|
|
484
|
+
notVerified: NOT_VERIFIED,
|
|
485
|
+
refusedBehavior: REFUSED_BEHAVIOR,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const projectRoot = context.projectRoot;
|
|
490
|
+
const git = gitSummary(projectRoot);
|
|
491
|
+
if (context.present && !context.enabled) {
|
|
492
|
+
return {
|
|
493
|
+
status: "partial",
|
|
494
|
+
coreVersion: PILOT_VERSION,
|
|
495
|
+
projectRoot,
|
|
496
|
+
adapter: context,
|
|
497
|
+
git,
|
|
498
|
+
scopePaths: [],
|
|
499
|
+
ignoredPaths: DEFAULT_IGNORED_PATHS,
|
|
500
|
+
filesScanned: [],
|
|
501
|
+
contractFiles: [],
|
|
502
|
+
endpointDeclarations: [],
|
|
503
|
+
clientCalls: [],
|
|
504
|
+
schemaFiles: [],
|
|
505
|
+
skipped: [{ path: ".", reason: "project adapter is present but does not enable api-contract-audit" }],
|
|
506
|
+
warnings: [
|
|
507
|
+
"api-contract-audit is not enabled by the project adapter; target files were not read",
|
|
508
|
+
...git.warnings,
|
|
509
|
+
],
|
|
510
|
+
notVerified: NOT_VERIFIED,
|
|
511
|
+
refusedBehavior: REFUSED_BEHAVIOR,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const scope = context.enabled
|
|
516
|
+
? scopeFromAdapter(context)
|
|
517
|
+
: { scopePaths: ["."], ignoredPaths: DEFAULT_IGNORED_PATHS };
|
|
518
|
+
const collected = collectFiles(projectRoot, scope.scopePaths, scope.ignoredPaths);
|
|
519
|
+
const scanned = scanContracts(projectRoot, collected.files);
|
|
520
|
+
return {
|
|
521
|
+
status: "complete",
|
|
522
|
+
coreVersion: PILOT_VERSION,
|
|
523
|
+
projectRoot,
|
|
524
|
+
adapter: context,
|
|
525
|
+
git,
|
|
526
|
+
scopePaths: scope.scopePaths,
|
|
527
|
+
ignoredPaths: scope.ignoredPaths,
|
|
528
|
+
filesScanned: collected.files,
|
|
529
|
+
contractFiles: scanned.contractFiles,
|
|
530
|
+
endpointDeclarations: scanned.endpointDeclarations,
|
|
531
|
+
clientCalls: scanned.clientCalls,
|
|
532
|
+
schemaFiles: scanned.schemaFiles,
|
|
533
|
+
skipped: collected.skipped,
|
|
534
|
+
warnings: [
|
|
535
|
+
...git.warnings,
|
|
536
|
+
...scanned.warnings,
|
|
537
|
+
...(context.enabled ? ["api-contract-audit used adapter-declared safe read paths only"] : []),
|
|
538
|
+
...(context.present ? [] : ["no project adapter declaration found; api-contract-audit used generic bounded static scan"]),
|
|
539
|
+
],
|
|
540
|
+
notVerified: NOT_VERIFIED,
|
|
541
|
+
refusedBehavior: REFUSED_BEHAVIOR,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function renderRecords(records, formatter) {
|
|
546
|
+
if (!records.length) return ["- none found"];
|
|
547
|
+
return records.slice(0, 80).map(formatter).concat(
|
|
548
|
+
records.length > 80 ? [`- ${records.length - 80} additional records omitted`] : [],
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function renderApiContractAuditReport(report) {
|
|
553
|
+
const lines = [
|
|
554
|
+
"# API Contract Audit Report",
|
|
555
|
+
"",
|
|
556
|
+
`Status: ${report.status}`,
|
|
557
|
+
`Core version: ${report.coreVersion}`,
|
|
558
|
+
`Project root: ${report.projectRoot}`,
|
|
559
|
+
"",
|
|
560
|
+
"## Git State",
|
|
561
|
+
`- Git root: ${report.git.root ?? "not detected"}`,
|
|
562
|
+
`- Branch state: ${report.git.branchState ?? "not detected"}`,
|
|
563
|
+
`- Local changes: ${report.git.hasUncommittedChanges ? "detected" : "not detected"}`,
|
|
564
|
+
"",
|
|
565
|
+
"## Adapter Scope",
|
|
566
|
+
`- Adapter present: ${report.adapter.present ? "yes" : "no"}`,
|
|
567
|
+
`- API-contract-audit enabled: ${report.adapter.enabled ? "yes" : "no"}`,
|
|
568
|
+
`- Mode: ${report.adapter.mode}`,
|
|
569
|
+
"",
|
|
570
|
+
"## Scope Paths",
|
|
571
|
+
...(report.scopePaths.length ? report.scopePaths.map((item) => `- ${item}`) : ["- none"]),
|
|
572
|
+
"",
|
|
573
|
+
"## Ignored Paths",
|
|
574
|
+
...report.ignoredPaths.map((item) => `- ${item}`),
|
|
575
|
+
"",
|
|
576
|
+
"## Summary",
|
|
577
|
+
`- Static files scanned: ${report.filesScanned.length}`,
|
|
578
|
+
`- Contract files: ${report.contractFiles.length}`,
|
|
579
|
+
`- Endpoint declarations: ${report.endpointDeclarations.length}`,
|
|
580
|
+
`- Client call patterns: ${report.clientCalls.length}`,
|
|
581
|
+
`- Schema/type files: ${report.schemaFiles.length}`,
|
|
582
|
+
`- Skipped items: ${report.skipped.length}`,
|
|
583
|
+
"",
|
|
584
|
+
"## Contract Files",
|
|
585
|
+
...renderRecords(
|
|
586
|
+
report.contractFiles,
|
|
587
|
+
(record) => `- ${record.path}: ${record.reasons.join(", ")}`,
|
|
588
|
+
),
|
|
589
|
+
"",
|
|
590
|
+
"## Endpoint Declarations",
|
|
591
|
+
...renderRecords(
|
|
592
|
+
report.endpointDeclarations,
|
|
593
|
+
(record) => `- ${record.method ?? "UNKNOWN"} ${record.route} in ${record.path} (${record.source})`,
|
|
594
|
+
),
|
|
595
|
+
"",
|
|
596
|
+
"## Client Call Patterns",
|
|
597
|
+
...renderRecords(
|
|
598
|
+
report.clientCalls,
|
|
599
|
+
(record) => `- ${record.method ?? "UNKNOWN"} ${record.target} in ${record.path} (${record.source})`,
|
|
600
|
+
),
|
|
601
|
+
"",
|
|
602
|
+
"## Schema And Type Files",
|
|
603
|
+
...renderRecords(
|
|
604
|
+
report.schemaFiles,
|
|
605
|
+
(record) => `- ${record.path}: ${record.reasons.join(", ")}`,
|
|
606
|
+
),
|
|
607
|
+
"",
|
|
608
|
+
"## Skipped",
|
|
609
|
+
];
|
|
610
|
+
if (report.skipped.length) {
|
|
611
|
+
for (const skipped of report.skipped.slice(0, 40)) {
|
|
612
|
+
lines.push(`- ${skipped.path}: ${skipped.reason}`);
|
|
613
|
+
}
|
|
614
|
+
if (report.skipped.length > 40) lines.push(`- ${report.skipped.length - 40} additional skipped items omitted`);
|
|
615
|
+
} else {
|
|
616
|
+
lines.push("- none");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
lines.push("", "## Not Verified");
|
|
620
|
+
for (const item of report.notVerified) lines.push(`- ${item}`);
|
|
621
|
+
lines.push("", "## Warnings");
|
|
622
|
+
if (report.warnings.length) {
|
|
623
|
+
for (const warning of report.warnings) lines.push(`- ${warning}`);
|
|
624
|
+
} else {
|
|
625
|
+
lines.push("- none");
|
|
626
|
+
}
|
|
627
|
+
lines.push("", "## Refused Behavior");
|
|
628
|
+
for (const item of report.refusedBehavior) lines.push(`- ${item}`);
|
|
629
|
+
lines.push(
|
|
630
|
+
"",
|
|
631
|
+
"No target project build, test, runtime, URL probe, API call, schema generation, client generation, deployment, migration, package installation, secret-file read, or project write was performed.",
|
|
632
|
+
);
|
|
633
|
+
return lines.join("\n");
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export function apiContractAuditCliResult(projectRootInput, options = {}) {
|
|
637
|
+
if (!projectRootInput) {
|
|
638
|
+
return {
|
|
639
|
+
exitCode: 2,
|
|
640
|
+
stream: "stderr",
|
|
641
|
+
lines: ["usage: node scripts/render-api-contract-audit.mjs <project-root>"],
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
const report = buildApiContractAuditReport(projectRootInput, options);
|
|
645
|
+
return {
|
|
646
|
+
exitCode: report.status === "failed" ? 1 : 0,
|
|
647
|
+
stream: report.status === "failed" ? "stderr" : "stdout",
|
|
648
|
+
lines: renderApiContractAuditReport(report).split("\n"),
|
|
649
|
+
report,
|
|
650
|
+
};
|
|
651
|
+
}
|