@cosmicdrift/kumiko-guards 0.363.0 → 0.364.0
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/README.md +28 -0
- package/package.json +2 -2
- package/src/_lib/agent-docs.ts +425 -0
- package/src/_lib/dependency-cli.ts +27 -0
- package/src/_lib/dependency-exceptions.ts +135 -0
- package/src/_lib/direct-dependencies.ts +80 -0
- package/src/_lib/installed-packages.ts +146 -0
- package/src/_lib/npm-registry.ts +168 -0
- package/src/_lib/security-audit.ts +390 -0
- package/src/changes.json +35 -5
- package/src/check-licenses.ts +288 -0
- package/src/check-outdated.ts +234 -0
- package/src/check-runtime-isolation.ts +3 -1
- package/src/check-security.ts +182 -0
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/cli.ts +26 -9
- package/src/dependency-license-exceptions.json +77 -0
- package/src/dependency-security-exceptions.json +4 -0
- package/src/guard-agent-docs-drift.ts +137 -0
- package/src/guard-comment-lang.ts +76 -40
- package/src/guard-direct-entity-writes.ts +3 -0
- package/src/guard-pre-es-patterns.ts +3 -0
- package/src/guard-public-exports-changelog.ts +22 -9
- package/src/guard-thin-wrappers.ts +9 -2
- package/src/guard-upgrade-state.ts +40 -11
- package/src/index.ts +1 -0
- package/src/pre-push.sh +19 -0
- package/src/run-repo-checks.ts +2 -0
package/README.md
CHANGED
|
@@ -15,6 +15,34 @@ repo's root. `--explain` prints the resolved repo and per-guard scan scope
|
|
|
15
15
|
without running any guard; `--write-security-baseline` freezes current
|
|
16
16
|
findings into the baseline file.
|
|
17
17
|
|
|
18
|
+
## Dependency checks
|
|
19
|
+
|
|
20
|
+
Three subcommands check the dependencies of the repo `cwd` sits in. They call
|
|
21
|
+
the npm registry or `bun audit`, so they are not part of the default run and
|
|
22
|
+
not part of `checks`; each takes `--report` (print only, exit 0; for `outdated`
|
|
23
|
+
it adds transitive and major-lag sections and a deprecated direct dependency
|
|
24
|
+
still exits 1). `outdated` lists every package the registry did not answer
|
|
25
|
+
as UNKNOWN with the reason and fails when no direct dependency got an answer.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
bunx kumiko-guards licenses # licence allow/gray/deny classification of installed packages
|
|
29
|
+
bunx kumiko-guards security # bun audit: critical/high block, moderate warns
|
|
30
|
+
bunx kumiko-guards outdated # installed direct dependencies the registry marks deprecated
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Installed packages (`licenses`, `outdated`) are read from `node_modules` of the
|
|
34
|
+
repo root, its workspace members and every ancestor directory, the resolution
|
|
35
|
+
Bun itself uses, so a hoisted parent workspace and `.wt/` worktrees work. If
|
|
36
|
+
nothing is found the command fails ("nothing scanned"). `security` audits the
|
|
37
|
+
repo root's `bun.lock`, or the nearest ancestor's (announced) when the repo has
|
|
38
|
+
none, and fails if there is no lockfile at all.
|
|
39
|
+
|
|
40
|
+
Exceptions need a reason, a reviewer and an expiry date. The package ships
|
|
41
|
+
defaults (`dependency-license-exceptions.json`,
|
|
42
|
+
`dependency-security-exceptions.json`); a file of the same name in the repo
|
|
43
|
+
root (and, for `licenses`, in an ancestor whose `node_modules` was scanned) is
|
|
44
|
+
merged on top. A security exception that expires within 14 days blocks.
|
|
45
|
+
|
|
18
46
|
## Raw-SQL markers
|
|
19
47
|
|
|
20
48
|
`guard-raw-sql` blocks `.unsafe()` / `asRawClient()` outside `db/queries/*`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.364.0",
|
|
4
4
|
"description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"kumiko-pre-push": "./src/pre-push.sh"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@cosmicdrift/kumiko-repo-manifest": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.364.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmdirSync,
|
|
7
|
+
statSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { type ExportedDeclarations, type JSDocableNode, Node, Project, ts } from "ts-morph";
|
|
13
|
+
|
|
14
|
+
export const AGENT_DOCS_DIR = "packages/framework/agents";
|
|
15
|
+
|
|
16
|
+
const FEATURE_MANIFEST_REL = "samples/apps/use-all-bundled/feature-manifest.json";
|
|
17
|
+
const RENDERER_WEB_INDEX_REL = "packages/renderer-web/src/index.ts";
|
|
18
|
+
const RECIPES_REL = "samples/recipes";
|
|
19
|
+
const RECIPE_SLUG_RE = /^[a-z0-9-]+$/;
|
|
20
|
+
const SOURCE_EXTENSIONS = [".ts", ".tsx"];
|
|
21
|
+
|
|
22
|
+
type ManifestConfigKeyDoc = {
|
|
23
|
+
readonly key: string;
|
|
24
|
+
readonly type: string;
|
|
25
|
+
readonly scope: string;
|
|
26
|
+
readonly default: string | number | boolean | null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type ManifestSecretDoc = {
|
|
30
|
+
readonly qualifiedName: string;
|
|
31
|
+
readonly scope: string;
|
|
32
|
+
readonly label: string | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type ManifestFeatureDoc = {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly description: string | null;
|
|
38
|
+
readonly category: string | null;
|
|
39
|
+
readonly requires: readonly string[];
|
|
40
|
+
readonly optionalRequires: readonly string[];
|
|
41
|
+
readonly exposesApis: readonly string[];
|
|
42
|
+
readonly usesApis: readonly string[];
|
|
43
|
+
readonly configKeys: readonly ManifestConfigKeyDoc[];
|
|
44
|
+
readonly secrets: readonly ManifestSecretDoc[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isStringArray(value: unknown): value is readonly string[] {
|
|
52
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isNullableString(value: unknown): value is string | null {
|
|
56
|
+
return value === null || typeof value === "string";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isConfigKeyDoc(value: unknown): value is ManifestConfigKeyDoc {
|
|
60
|
+
if (!isRecord(value)) return false;
|
|
61
|
+
const fallback = value["default"];
|
|
62
|
+
return (
|
|
63
|
+
typeof value["key"] === "string" &&
|
|
64
|
+
typeof value["type"] === "string" &&
|
|
65
|
+
typeof value["scope"] === "string" &&
|
|
66
|
+
(fallback === null ||
|
|
67
|
+
typeof fallback === "string" ||
|
|
68
|
+
typeof fallback === "number" ||
|
|
69
|
+
typeof fallback === "boolean")
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isSecretDoc(value: unknown): value is ManifestSecretDoc {
|
|
74
|
+
return (
|
|
75
|
+
isRecord(value) &&
|
|
76
|
+
typeof value["qualifiedName"] === "string" &&
|
|
77
|
+
typeof value["scope"] === "string" &&
|
|
78
|
+
isNullableString(value["label"])
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseFeatureDoc(value: unknown): ManifestFeatureDoc | undefined {
|
|
83
|
+
if (!isRecord(value) || typeof value["name"] !== "string") return undefined;
|
|
84
|
+
const description = value["description"];
|
|
85
|
+
const configKeys = value["configKeys"];
|
|
86
|
+
const secrets = value["secrets"];
|
|
87
|
+
const uiHints = value["uiHints"];
|
|
88
|
+
const category = isRecord(uiHints) ? uiHints["category"] : undefined;
|
|
89
|
+
if (
|
|
90
|
+
!isNullableString(description) ||
|
|
91
|
+
!isStringArray(value["requires"]) ||
|
|
92
|
+
!isStringArray(value["optionalRequires"]) ||
|
|
93
|
+
!isStringArray(value["exposesApis"]) ||
|
|
94
|
+
!isStringArray(value["usesApis"]) ||
|
|
95
|
+
!Array.isArray(configKeys) ||
|
|
96
|
+
!configKeys.every(isConfigKeyDoc) ||
|
|
97
|
+
!Array.isArray(secrets) ||
|
|
98
|
+
!secrets.every(isSecretDoc)
|
|
99
|
+
) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
name: value["name"],
|
|
104
|
+
description,
|
|
105
|
+
category: typeof category === "string" ? category : null,
|
|
106
|
+
requires: value["requires"],
|
|
107
|
+
optionalRequires: value["optionalRequires"],
|
|
108
|
+
exposesApis: value["exposesApis"],
|
|
109
|
+
usesApis: value["usesApis"],
|
|
110
|
+
configKeys,
|
|
111
|
+
secrets,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseJsonFile(relPath: string, raw: string): unknown {
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(raw);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new Error(`${relPath}: invalid JSON`, { cause: error });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseFeatureManifest(raw: string): readonly ManifestFeatureDoc[] {
|
|
124
|
+
const parsed = parseJsonFile(FEATURE_MANIFEST_REL, raw);
|
|
125
|
+
const features = isRecord(parsed) ? parsed["features"] : undefined;
|
|
126
|
+
if (!Array.isArray(features)) {
|
|
127
|
+
throw new Error(`${FEATURE_MANIFEST_REL}: "features" must be an array`);
|
|
128
|
+
}
|
|
129
|
+
return features.map((entry, index) => {
|
|
130
|
+
const feature = parseFeatureDoc(entry);
|
|
131
|
+
if (feature === undefined) {
|
|
132
|
+
throw new Error(`${FEATURE_MANIFEST_REL}: features[${index}] has an unexpected shape`);
|
|
133
|
+
}
|
|
134
|
+
return feature;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function compareCodepoint(left: string, right: string): number {
|
|
139
|
+
if (left < right) return -1;
|
|
140
|
+
return left > right ? 1 : 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function generatedHeader(source: string): string {
|
|
144
|
+
return `<!-- Generated by \`bun run gen:agent-docs\` from ${source}. Do not edit. -->`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function finishFile(source: string, body: string): string {
|
|
148
|
+
const normalized = body.replace(/\r\n/g, "\n").trim();
|
|
149
|
+
return `${generatedHeader(source)}\n\n${normalized}\n`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function escapeCell(text: string): string {
|
|
153
|
+
return text.replace(/\s+/g, " ").trim().replace(/\|/g, "\\|");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function table(headers: readonly string[], rows: readonly (readonly string[])[]): string {
|
|
157
|
+
const lines = [
|
|
158
|
+
`| ${headers.join(" | ")} |`,
|
|
159
|
+
`| ${headers.map(() => "---").join(" | ")} |`,
|
|
160
|
+
...rows.map((row) => `| ${row.map(escapeCell).join(" | ")} |`),
|
|
161
|
+
];
|
|
162
|
+
return lines.join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function codeList(values: readonly string[]): string {
|
|
166
|
+
return values.map((value) => `\`${value}\``).join(", ");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderFeatureSection(feature: ManifestFeatureDoc): string {
|
|
170
|
+
const parts = [`## ${feature.name}`];
|
|
171
|
+
if (feature.description !== null) parts.push(feature.description.replace(/\s+/g, " ").trim());
|
|
172
|
+
if (feature.category !== null) parts.push(`Category: ${feature.category}`);
|
|
173
|
+
if (feature.requires.length > 0) parts.push(`Requires: ${codeList(feature.requires)}`);
|
|
174
|
+
if (feature.optionalRequires.length > 0) {
|
|
175
|
+
parts.push(`Optional requires: ${codeList(feature.optionalRequires)}`);
|
|
176
|
+
}
|
|
177
|
+
if (feature.configKeys.length > 0) {
|
|
178
|
+
const rows = feature.configKeys.map((configKey) => [
|
|
179
|
+
`\`${configKey.key}\``,
|
|
180
|
+
configKey.type,
|
|
181
|
+
configKey.scope,
|
|
182
|
+
configKey.default === null ? "" : `\`${String(configKey.default)}\``,
|
|
183
|
+
]);
|
|
184
|
+
parts.push(`Config keys:\n\n${table(["Key", "Type", "Scope", "Default"], rows)}`);
|
|
185
|
+
}
|
|
186
|
+
if (feature.secrets.length > 0) {
|
|
187
|
+
const rows = feature.secrets.map((secret) => [
|
|
188
|
+
`\`${secret.qualifiedName}\``,
|
|
189
|
+
secret.scope,
|
|
190
|
+
secret.label ?? "",
|
|
191
|
+
]);
|
|
192
|
+
parts.push(`Secrets:\n\n${table(["Secret", "Scope", "Label"], rows)}`);
|
|
193
|
+
}
|
|
194
|
+
if (feature.exposesApis.length > 0) parts.push(`Exposes APIs: ${codeList(feature.exposesApis)}`);
|
|
195
|
+
if (feature.usesApis.length > 0) parts.push(`Uses APIs: ${codeList(feature.usesApis)}`);
|
|
196
|
+
return parts.join("\n\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function renderFeatures(repoRoot: string): string {
|
|
200
|
+
const raw = readFileSync(join(repoRoot, FEATURE_MANIFEST_REL), "utf-8");
|
|
201
|
+
const sections = [...parseFeatureManifest(raw)]
|
|
202
|
+
.sort((left, right) => compareCodepoint(left.name, right.name))
|
|
203
|
+
.map(renderFeatureSection);
|
|
204
|
+
return finishFile(FEATURE_MANIFEST_REL, `# Bundled features\n\n${sections.join("\n\n")}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
type WidgetGroup = "Components" | "Hooks" | "Functions" | "Constants" | "Classes";
|
|
208
|
+
const WIDGET_GROUP_ORDER: readonly WidgetGroup[] = [
|
|
209
|
+
"Components",
|
|
210
|
+
"Hooks",
|
|
211
|
+
"Functions",
|
|
212
|
+
"Constants",
|
|
213
|
+
"Classes",
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
const HOOK_NAME_RE = /^use[A-Z]/;
|
|
217
|
+
const PASCAL_CASE_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
218
|
+
|
|
219
|
+
function isFunctionLike(node: Node | undefined): boolean {
|
|
220
|
+
return node !== undefined && (Node.isArrowFunction(node) || Node.isFunctionExpression(node));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isPascalCaseComponentName(name: string): boolean {
|
|
224
|
+
return PASCAL_CASE_RE.test(name) && /[a-z]/.test(name);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function widgetGroupOf(name: string, declaration: ExportedDeclarations): WidgetGroup | undefined {
|
|
228
|
+
if (Node.isClassDeclaration(declaration)) return "Classes";
|
|
229
|
+
if (Node.isEnumDeclaration(declaration)) return "Constants";
|
|
230
|
+
const isFunction = Node.isFunctionDeclaration(declaration);
|
|
231
|
+
const isVariable = Node.isVariableDeclaration(declaration);
|
|
232
|
+
if (!isFunction && !isVariable) return undefined;
|
|
233
|
+
if (HOOK_NAME_RE.test(name)) return "Hooks";
|
|
234
|
+
if (isPascalCaseComponentName(name)) return "Components";
|
|
235
|
+
if (isFunction || isFunctionLike(declaration.getInitializer())) return "Functions";
|
|
236
|
+
return "Constants";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function jsDocOwnerOf(declaration: ExportedDeclarations): JSDocableNode | undefined {
|
|
240
|
+
if (Node.isVariableDeclaration(declaration)) return declaration.getVariableStatement();
|
|
241
|
+
return Node.isJSDocable(declaration) ? declaration : undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const FIRST_SENTENCE_RE = /^(.+?(?<!\be\.g|\bi\.e)[.!?])(?:\s|$)/;
|
|
245
|
+
|
|
246
|
+
function firstSentence(text: string): string {
|
|
247
|
+
const flattened = text.replace(/\s+/g, " ").trim();
|
|
248
|
+
return FIRST_SENTENCE_RE.exec(flattened)?.[1] ?? flattened;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function descriptionOf(declarations: readonly ExportedDeclarations[]): string {
|
|
252
|
+
for (const declaration of declarations) {
|
|
253
|
+
const docs = jsDocOwnerOf(declaration)?.getJsDocs() ?? [];
|
|
254
|
+
const description = docs.at(-1)?.getDescription() ?? "";
|
|
255
|
+
if (description.trim() !== "") return firstSentence(description);
|
|
256
|
+
}
|
|
257
|
+
return "";
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function renderWidgets(repoRoot: string): string {
|
|
261
|
+
const project = new Project({
|
|
262
|
+
compilerOptions: {
|
|
263
|
+
module: ts.ModuleKind.ESNext,
|
|
264
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
265
|
+
target: ts.ScriptTarget.ES2022,
|
|
266
|
+
jsx: ts.JsxEmit.ReactJSX,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
const indexFile = project.addSourceFileAtPath(join(repoRoot, RENDERER_WEB_INDEX_REL));
|
|
270
|
+
const grouped = new Map<WidgetGroup, [string, string][]>(
|
|
271
|
+
WIDGET_GROUP_ORDER.map((group) => [group, []]),
|
|
272
|
+
);
|
|
273
|
+
for (const [name, declarations] of indexFile.getExportedDeclarations()) {
|
|
274
|
+
const group = declarations
|
|
275
|
+
.map((declaration) => widgetGroupOf(name, declaration))
|
|
276
|
+
.find((candidate) => candidate !== undefined);
|
|
277
|
+
if (group === undefined) continue;
|
|
278
|
+
grouped.get(group)?.push([name, descriptionOf(declarations)]);
|
|
279
|
+
}
|
|
280
|
+
const sections: string[] = [];
|
|
281
|
+
for (const group of WIDGET_GROUP_ORDER) {
|
|
282
|
+
const entries = (grouped.get(group) ?? []).sort(([left], [right]) =>
|
|
283
|
+
compareCodepoint(left, right),
|
|
284
|
+
);
|
|
285
|
+
if (entries.length === 0) continue;
|
|
286
|
+
const rows = entries.map(([name, description]) => [`\`${name}\``, description]);
|
|
287
|
+
sections.push(`## ${group}\n\n${table(["Name", "Description"], rows)}`);
|
|
288
|
+
}
|
|
289
|
+
const intro = "Value exports of `@cosmicdrift/kumiko-renderer-web`.";
|
|
290
|
+
return finishFile(RENDERER_WEB_INDEX_REL, `# Widgets\n\n${intro}\n\n${sections.join("\n\n")}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
type Recipe = {
|
|
294
|
+
readonly slug: string;
|
|
295
|
+
readonly dir: string;
|
|
296
|
+
readonly description: string;
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
function readRecipes(repoRoot: string): readonly Recipe[] {
|
|
300
|
+
const recipesDir = join(repoRoot, RECIPES_REL);
|
|
301
|
+
if (!existsSync(recipesDir)) return [];
|
|
302
|
+
const recipes: Recipe[] = [];
|
|
303
|
+
for (const entry of readdirSync(recipesDir, { withFileTypes: true })) {
|
|
304
|
+
if (!entry.isDirectory()) continue;
|
|
305
|
+
const dir = join(recipesDir, entry.name);
|
|
306
|
+
const packageJsonPath = join(dir, "package.json");
|
|
307
|
+
if (!existsSync(packageJsonPath)) continue;
|
|
308
|
+
if (!RECIPE_SLUG_RE.test(entry.name)) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
`${RECIPES_REL}/${entry.name}: recipe directory must match ${RECIPE_SLUG_RE}`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
const parsed = parseJsonFile(
|
|
314
|
+
`${RECIPES_REL}/${entry.name}/package.json`,
|
|
315
|
+
readFileSync(packageJsonPath, "utf-8"),
|
|
316
|
+
);
|
|
317
|
+
const description = isRecord(parsed) ? parsed["description"] : undefined;
|
|
318
|
+
recipes.push({
|
|
319
|
+
slug: entry.name,
|
|
320
|
+
dir,
|
|
321
|
+
description: typeof description === "string" ? description : "",
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return recipes.sort((left, right) => compareCodepoint(left.slug, right.slug));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function renderExamplesIndex(recipes: readonly Recipe[]): string {
|
|
328
|
+
const rows = recipes.map((recipe) => [
|
|
329
|
+
`\`${recipe.slug}\``,
|
|
330
|
+
recipe.description,
|
|
331
|
+
`\`examples/${recipe.slug}.md\``,
|
|
332
|
+
]);
|
|
333
|
+
return finishFile(
|
|
334
|
+
`${RECIPES_REL}/*/package.json`,
|
|
335
|
+
`# Examples\n\n${table(["Example", "Description", "File"], rows)}`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function listSourceFiles(dir: string, prefix = ""): string[] {
|
|
340
|
+
if (!existsSync(dir)) return [];
|
|
341
|
+
const found: string[] = [];
|
|
342
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
343
|
+
const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
344
|
+
if (entry.isDirectory()) {
|
|
345
|
+
found.push(...listSourceFiles(join(dir, entry.name), rel));
|
|
346
|
+
} else if (SOURCE_EXTENSIONS.some((extension) => entry.name.endsWith(extension))) {
|
|
347
|
+
found.push(rel);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return found.sort(compareCodepoint);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function fenceFor(content: string): string {
|
|
354
|
+
const longestRun = Math.max(0, ...(content.match(/`+/g) ?? []).map((run) => run.length));
|
|
355
|
+
return "`".repeat(Math.max(3, longestRun + 1));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function renderRecipe(recipe: Recipe): string {
|
|
359
|
+
const parts = [`# ${recipe.slug}`];
|
|
360
|
+
if (recipe.description !== "") parts.push(recipe.description);
|
|
361
|
+
const readmePath = join(recipe.dir, "README.md");
|
|
362
|
+
if (existsSync(readmePath)) {
|
|
363
|
+
parts.push(`## README\n\n${readFileSync(readmePath, "utf-8").replace(/\r\n/g, "\n").trim()}`);
|
|
364
|
+
}
|
|
365
|
+
const sourceBlocks = listSourceFiles(join(recipe.dir, "src")).map((rel) => {
|
|
366
|
+
const content = readFileSync(join(recipe.dir, "src", rel), "utf-8")
|
|
367
|
+
.replace(/\r\n/g, "\n")
|
|
368
|
+
.trimEnd();
|
|
369
|
+
const fence = fenceFor(content);
|
|
370
|
+
const language = rel.endsWith(".tsx") ? "tsx" : "ts";
|
|
371
|
+
return `### src/${rel}\n\n${fence}${language}\n${content}\n${fence}`;
|
|
372
|
+
});
|
|
373
|
+
if (sourceBlocks.length > 0) parts.push(`## Source files\n\n${sourceBlocks.join("\n\n")}`);
|
|
374
|
+
return finishFile(`${RECIPES_REL}/${recipe.slug}`, parts.join("\n\n"));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function renderAgentDocs(repoRoot: string): ReadonlyMap<string, string> {
|
|
378
|
+
const recipes = readRecipes(repoRoot);
|
|
379
|
+
const files = new Map<string, string>();
|
|
380
|
+
files.set("features.md", renderFeatures(repoRoot));
|
|
381
|
+
files.set("widgets.md", renderWidgets(repoRoot));
|
|
382
|
+
files.set("examples.md", renderExamplesIndex(recipes));
|
|
383
|
+
for (const recipe of recipes) files.set(`examples/${recipe.slug}.md`, renderRecipe(recipe));
|
|
384
|
+
return files;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function listFilesRecursive(dir: string, prefix = ""): string[] {
|
|
388
|
+
if (!existsSync(dir)) return [];
|
|
389
|
+
const found: string[] = [];
|
|
390
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
391
|
+
const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
392
|
+
if (entry.isDirectory()) found.push(...listFilesRecursive(join(dir, entry.name), rel));
|
|
393
|
+
else found.push(rel);
|
|
394
|
+
}
|
|
395
|
+
return found.sort(compareCodepoint);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function readAgentDocsOnDisk(repoRoot: string): ReadonlyMap<string, string> {
|
|
399
|
+
const dir = join(repoRoot, AGENT_DOCS_DIR);
|
|
400
|
+
return new Map(
|
|
401
|
+
listFilesRecursive(dir).map((rel) => [rel, readFileSync(join(dir, rel), "utf-8")]),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function removeEmptyDirs(dir: string): void {
|
|
406
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
407
|
+
if (entry.isDirectory()) removeEmptyDirs(join(dir, entry.name));
|
|
408
|
+
}
|
|
409
|
+
if (readdirSync(dir).length === 0) rmdirSync(dir);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function writeAgentDocs(repoRoot: string): number {
|
|
413
|
+
const outDir = join(repoRoot, AGENT_DOCS_DIR);
|
|
414
|
+
const files = renderAgentDocs(repoRoot);
|
|
415
|
+
for (const [rel, content] of files) {
|
|
416
|
+
const target = join(outDir, rel);
|
|
417
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
418
|
+
writeFileSync(target, content, "utf-8");
|
|
419
|
+
}
|
|
420
|
+
for (const rel of listFilesRecursive(outDir)) {
|
|
421
|
+
if (!files.has(rel)) unlinkSync(join(outDir, rel));
|
|
422
|
+
}
|
|
423
|
+
if (existsSync(outDir) && statSync(outDir).isDirectory()) removeEmptyDirs(outDir);
|
|
424
|
+
return files.size;
|
|
425
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cliFlagsError } from "./guard-kit";
|
|
2
|
+
import { findLocalRepo } from "./roots";
|
|
3
|
+
|
|
4
|
+
export const DEPENDENCY_CHECK_FLAGS: readonly string[] = ["--report"];
|
|
5
|
+
|
|
6
|
+
export type DependencyCliInvocation =
|
|
7
|
+
| { readonly ok: true; readonly repoRoot: string; readonly reportOnly: boolean }
|
|
8
|
+
| { readonly ok: false; readonly exitCode: number };
|
|
9
|
+
|
|
10
|
+
/** Shared argv + repo resolution of the licenses/security/outdated subcommands. */
|
|
11
|
+
export function parseDependencyCli(
|
|
12
|
+
subcommand: string,
|
|
13
|
+
argv: readonly string[],
|
|
14
|
+
cwd: string,
|
|
15
|
+
): DependencyCliInvocation {
|
|
16
|
+
const flagsError = cliFlagsError(subcommand, argv, DEPENDENCY_CHECK_FLAGS);
|
|
17
|
+
if (flagsError !== undefined) {
|
|
18
|
+
console.error(flagsError);
|
|
19
|
+
return { ok: false, exitCode: 1 };
|
|
20
|
+
}
|
|
21
|
+
const repo = findLocalRepo(cwd);
|
|
22
|
+
if (!repo) {
|
|
23
|
+
console.error(` ${subcommand}: no Kumiko repo root found above ${cwd}.`);
|
|
24
|
+
return { ok: false, exitCode: 1 };
|
|
25
|
+
}
|
|
26
|
+
return { ok: true, repoRoot: repo.absPath, reportOnly: argv.includes("--report") };
|
|
27
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exception files for the dependency checks (licenses, security). Merged: the
|
|
3
|
+
* defaults bundled next to this package's sources and an optional file of the
|
|
4
|
+
* same name in each given root (the scanned repo's root, plus any ancestor
|
|
5
|
+
* whose installation the scan read packages or a lockfile from).
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
const BUNDLED_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
|
|
14
|
+
export type ExceptionValidity = { readonly valid: boolean; readonly expired: boolean };
|
|
15
|
+
|
|
16
|
+
/** Start of the day after `expires` (a `YYYY-MM-DD` date, UTC) in epoch ms; undefined when it is not a plain date. */
|
|
17
|
+
export function expiryEndMs(expires: string): number | undefined {
|
|
18
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(expires)) return undefined;
|
|
19
|
+
try {
|
|
20
|
+
return Temporal.PlainDate.from(expires).add({ days: 1 }).toZonedDateTime("UTC")
|
|
21
|
+
.epochMilliseconds;
|
|
22
|
+
} catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// An exception without an expiry never expires; callers that must not allow that (security) reject it on load.
|
|
28
|
+
export function isExceptionValid(
|
|
29
|
+
expires: string | undefined,
|
|
30
|
+
nowMs: number = Date.now(),
|
|
31
|
+
): ExceptionValidity {
|
|
32
|
+
if (!expires) return { valid: true, expired: false };
|
|
33
|
+
const endMs = expiryEndMs(expires);
|
|
34
|
+
if (endMs === undefined) return { valid: false, expired: false };
|
|
35
|
+
// The exception holds through the END of its expiry day.
|
|
36
|
+
const expired = nowMs >= endMs;
|
|
37
|
+
return { valid: !expired, expired };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function findExpiringSoon<T extends { readonly expires?: string }>(
|
|
41
|
+
items: readonly T[],
|
|
42
|
+
withinDays = 30,
|
|
43
|
+
nowMs: number = Date.now(),
|
|
44
|
+
): T[] {
|
|
45
|
+
const horizonMs = nowMs + withinDays * DAY_MS;
|
|
46
|
+
return items.filter((item) => {
|
|
47
|
+
const endMs = item.expires ? expiryEndMs(item.expires) : undefined;
|
|
48
|
+
return endMs !== undefined && nowMs < endMs && endMs - DAY_MS <= horizonMs;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Wildcard match for package names (trailing `*` only). */
|
|
53
|
+
export function matchesPackageName(pattern: string, name: string): boolean {
|
|
54
|
+
if (pattern === name) return true;
|
|
55
|
+
if (!pattern.endsWith("*")) return false;
|
|
56
|
+
const prefix = pattern.slice(0, -1);
|
|
57
|
+
return prefix.length > 0 && name.startsWith(prefix);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
61
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class MissingFieldError extends Error {
|
|
65
|
+
constructor(readonly field: string) {
|
|
66
|
+
super(`missing or blank "${field}"`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ExceptionSource = { readonly file: string; readonly origin: "bundled" | "repo" };
|
|
71
|
+
|
|
72
|
+
export function exceptionSources(
|
|
73
|
+
filename: string,
|
|
74
|
+
roots: readonly string[],
|
|
75
|
+
bundledDir = BUNDLED_DIR,
|
|
76
|
+
): ExceptionSource[] {
|
|
77
|
+
const sources: ExceptionSource[] = [
|
|
78
|
+
{ file: join(bundledDir, filename), origin: "bundled" },
|
|
79
|
+
...[...new Set(roots)].map((root) => ({ file: join(root, filename), origin: "repo" as const })),
|
|
80
|
+
];
|
|
81
|
+
return sources.filter((source) => existsSync(source.file));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readExceptionEntries(source: ExceptionSource, filename: string): unknown[] {
|
|
85
|
+
let parsed: unknown;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(readFileSync(source.file, "utf-8"));
|
|
88
|
+
} catch {
|
|
89
|
+
console.warn(
|
|
90
|
+
` WARN: ${source.origin} ${filename} (${source.file}) is not readable JSON — ignored.`,
|
|
91
|
+
);
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
const entries = isRecord(parsed) ? parsed["exceptions"] : undefined;
|
|
95
|
+
if (!Array.isArray(entries)) {
|
|
96
|
+
console.warn(` WARN: ${source.origin} ${filename} has no "exceptions" array — ignored.`);
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
return entries;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Loads and merges the exception entries of `filename`. `parse` builds one
|
|
104
|
+
* exception from its entry; asking `field(key)` for a missing or blank string
|
|
105
|
+
* drops that entry with a warning. A broken file or entry is never trusted: a
|
|
106
|
+
* dropped exception can only make a check stricter.
|
|
107
|
+
*/
|
|
108
|
+
export function loadExceptionEntries<T>(options: {
|
|
109
|
+
readonly filename: string;
|
|
110
|
+
readonly roots: readonly string[];
|
|
111
|
+
readonly parse: (field: (key: string) => string) => T;
|
|
112
|
+
readonly bundledDir?: string;
|
|
113
|
+
}): T[] {
|
|
114
|
+
const merged: T[] = [];
|
|
115
|
+
for (const source of exceptionSources(options.filename, options.roots, options.bundledDir)) {
|
|
116
|
+
for (const entry of readExceptionEntries(source, options.filename)) {
|
|
117
|
+
try {
|
|
118
|
+
if (!isRecord(entry)) throw new MissingFieldError("(entry is not an object)");
|
|
119
|
+
merged.push(
|
|
120
|
+
options.parse((key) => {
|
|
121
|
+
const value = entry[key];
|
|
122
|
+
if (typeof value !== "string" || value.trim() === "") throw new MissingFieldError(key);
|
|
123
|
+
return value;
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (!(err instanceof MissingFieldError)) throw err;
|
|
128
|
+
console.warn(
|
|
129
|
+
` WARN: ${source.origin} ${options.filename}: entry dropped, ${err.message}.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return merged;
|
|
135
|
+
}
|