peer-ai-standards 1.0.0-next.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/LICENSE +21 -0
- package/README.md +62 -0
- package/dist/core/ai-features.d.ts +65 -0
- package/dist/core/ai-features.js +113 -0
- package/dist/core/api-design.d.ts +41 -0
- package/dist/core/api-design.js +91 -0
- package/dist/core/architecture.d.ts +41 -0
- package/dist/core/architecture.js +93 -0
- package/dist/core/backend.d.ts +21 -0
- package/dist/core/backend.js +25 -0
- package/dist/core/code-quality.d.ts +91 -0
- package/dist/core/code-quality.js +179 -0
- package/dist/core/data.d.ts +45 -0
- package/dist/core/data.js +59 -0
- package/dist/core/delivery.d.ts +153 -0
- package/dist/core/delivery.js +149 -0
- package/dist/core/design-accessibility.d.ts +124 -0
- package/dist/core/design-accessibility.js +199 -0
- package/dist/core/frontend.d.ts +77 -0
- package/dist/core/frontend.js +109 -0
- package/dist/core/mobile.d.ts +46 -0
- package/dist/core/mobile.js +83 -0
- package/dist/core/money.d.ts +81 -0
- package/dist/core/money.js +135 -0
- package/dist/core/operations.d.ts +108 -0
- package/dist/core/operations.js +177 -0
- package/dist/core/performance.d.ts +53 -0
- package/dist/core/performance.js +85 -0
- package/dist/core/privacy-compliance.d.ts +68 -0
- package/dist/core/privacy-compliance.js +77 -0
- package/dist/core/reliability.d.ts +67 -0
- package/dist/core/reliability.js +105 -0
- package/dist/core/requirements.d.ts +41 -0
- package/dist/core/requirements.js +58 -0
- package/dist/core/safety-critical.d.ts +41 -0
- package/dist/core/safety-critical.js +71 -0
- package/dist/core/security.d.ts +281 -0
- package/dist/core/security.js +427 -0
- package/dist/core/system-design.d.ts +31 -0
- package/dist/core/system-design.js +69 -0
- package/dist/core/testing.d.ts +51 -0
- package/dist/core/testing.js +124 -0
- package/dist/domains.d.ts +6 -0
- package/dist/domains.js +53 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +118 -0
- package/dist/profile.d.ts +262 -0
- package/dist/profile.js +269 -0
- package/dist/profiles/express.d.ts +2 -0
- package/dist/profiles/express.js +93 -0
- package/dist/profiles/fastapi.d.ts +2 -0
- package/dist/profiles/fastapi.js +104 -0
- package/dist/profiles/fastify.d.ts +2 -0
- package/dist/profiles/fastify.js +56 -0
- package/dist/profiles/github-actions.d.ts +2 -0
- package/dist/profiles/github-actions.js +151 -0
- package/dist/profiles/nestjs.d.ts +2 -0
- package/dist/profiles/nestjs.js +77 -0
- package/dist/profiles/next.d.ts +2 -0
- package/dist/profiles/next.js +73 -0
- package/dist/profiles/node.d.ts +2 -0
- package/dist/profiles/node.js +79 -0
- package/dist/profiles/python.d.ts +2 -0
- package/dist/profiles/python.js +232 -0
- package/dist/profiles/react-native.d.ts +2 -0
- package/dist/profiles/react-native.js +133 -0
- package/dist/profiles/react.d.ts +2 -0
- package/dist/profiles/react.js +180 -0
- package/dist/profiles/typescript.d.ts +2 -0
- package/dist/profiles/typescript.js +214 -0
- package/dist/rule.d.ts +72 -0
- package/dist/rule.js +39 -0
- package/package.json +39 -0
package/dist/profile.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { CHECKS, SEVERITIES, TRAITS } from "peer-ai-workflow";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { SourceSchema, STAGES, traitsNeeded } from "./rule.js";
|
|
4
|
+
// A stack profile says how to follow core rules in one stack (RFC 0006). Each of its rules names
|
|
5
|
+
// the core rule it carries out, and may apply only to some architectures, hold a number the
|
|
6
|
+
// project can change, and name the tool setting that enforces it. An automatic rule comes with an
|
|
7
|
+
// example that must fail and one that must pass, which the tests run through the real tool.
|
|
8
|
+
const Text = z.string().min(1);
|
|
9
|
+
const Slug = z.string().regex(/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/, "use lowercase letters, digits and hyphens");
|
|
10
|
+
const CoreRuleId = z.string().regex(/^[A-Z][A-Z0-9]*-\d{2,}$/, "a core rule id, such as CODE-14");
|
|
11
|
+
const ValueSchema = z.union([z.number(), Text]);
|
|
12
|
+
/** The jobs of Peer AI's pipeline workflow that check a rule automatically (RFC 0006, section 4). */
|
|
13
|
+
export const PIPELINE_JOBS = ["secrets", "dependencies", "workflows", "code"];
|
|
14
|
+
/** Where an option holds this, the tool gets the rule's value: its default, or the project's. */
|
|
15
|
+
export const VALUE = "$value";
|
|
16
|
+
export const EnforcerSchema = z.discriminatedUnion("tool", [
|
|
17
|
+
z.strictObject({
|
|
18
|
+
tool: z.literal("eslint"),
|
|
19
|
+
rule: Text.describe("The ESLint rule, with its plugin's prefix, such as @typescript-eslint/no-explicit-any."),
|
|
20
|
+
options: z.array(z.unknown()).optional().describe(`The rule's options. "${VALUE}" stands for the rule's value.`),
|
|
21
|
+
typed: z.boolean().optional().describe("The rule needs type information, so it runs on TypeScript files only."),
|
|
22
|
+
files: z
|
|
23
|
+
.array(Text)
|
|
24
|
+
.min(1)
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("The files it applies to, as globs within the part, such as **/*.tsx. None: every script."),
|
|
27
|
+
ignores: z.array(Text).min(1).optional().describe("Files within those it leaves alone, such as **/*.test.*."),
|
|
28
|
+
}),
|
|
29
|
+
z.strictObject({
|
|
30
|
+
tool: z.literal("ruff"),
|
|
31
|
+
rule: z
|
|
32
|
+
.string()
|
|
33
|
+
.regex(/^[A-Z]+[0-9]+$/, "a Ruff rule code, such as S608")
|
|
34
|
+
.describe("The Ruff rule's code."),
|
|
35
|
+
settings: z
|
|
36
|
+
.record(z.string(), z.record(z.string(), z.unknown()))
|
|
37
|
+
.optional()
|
|
38
|
+
.describe(`Ruff's lint settings the rule reads, such as { pylint: { "max-statements": "${VALUE}" } }.`),
|
|
39
|
+
}),
|
|
40
|
+
z.strictObject({
|
|
41
|
+
tool: z.literal("github-actions"),
|
|
42
|
+
job: z.enum(PIPELINE_JOBS).describe("The job of Peer AI's pipeline workflow that runs the check."),
|
|
43
|
+
finding: Text.optional().describe("What the tool reports for this rule, where one job checks several rules."),
|
|
44
|
+
}),
|
|
45
|
+
z.strictObject({
|
|
46
|
+
tool: z.literal("typescript"),
|
|
47
|
+
option: Text.describe("The compiler option, such as strict."),
|
|
48
|
+
value: z.unknown().describe("The value it must have."),
|
|
49
|
+
}),
|
|
50
|
+
]);
|
|
51
|
+
const ExampleSchema = z.union([
|
|
52
|
+
Text,
|
|
53
|
+
z.custom((input) => typeof input === "function", "a string or a function of the value"),
|
|
54
|
+
]);
|
|
55
|
+
export const ExamplesSchema = z.strictObject({
|
|
56
|
+
file: Text.describe("The example's file name, which sets its language, such as example.ts."),
|
|
57
|
+
fails: ExampleSchema.describe("Code the enforcer must refuse."),
|
|
58
|
+
passes: ExampleSchema.describe("Code the enforcer must accept."),
|
|
59
|
+
});
|
|
60
|
+
export const ProfileRuleSchema = z
|
|
61
|
+
.strictObject({
|
|
62
|
+
id: z.string().regex(/^[A-Z][A-Z0-9]*-\d{2,}$/, "use the profile's prefix and a number, such as TS-01"),
|
|
63
|
+
title: Text.max(100),
|
|
64
|
+
rule: Text,
|
|
65
|
+
why: Text,
|
|
66
|
+
ask: Text.regex(/\?$/, "the ask is a question"),
|
|
67
|
+
stage: z.enum(STAGES),
|
|
68
|
+
check: z.enum(CHECKS),
|
|
69
|
+
severity: z.enum(SEVERITIES),
|
|
70
|
+
when: z.array(z.enum(TRAITS)).min(1).optional(),
|
|
71
|
+
sources: z.array(SourceSchema).min(1).optional(),
|
|
72
|
+
carries: CoreRuleId.describe("The core rule this rule carries out in the stack."),
|
|
73
|
+
architectures: z
|
|
74
|
+
.array(Slug)
|
|
75
|
+
.min(1)
|
|
76
|
+
.optional()
|
|
77
|
+
.describe("The architecture labels it applies to. A rule with none applies to every architecture."),
|
|
78
|
+
default: z
|
|
79
|
+
.strictObject({ value: ValueSchema, unit: Text.optional() })
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("A number or a choice the project may change, which {value} in the text stands for."),
|
|
82
|
+
enforcer: EnforcerSchema.optional(),
|
|
83
|
+
examples: ExamplesSchema.optional(),
|
|
84
|
+
})
|
|
85
|
+
.superRefine((rule, ctx) => {
|
|
86
|
+
const issue = (path, message) => {
|
|
87
|
+
ctx.addIssue({ code: "custom", path: [path], message });
|
|
88
|
+
};
|
|
89
|
+
if (rule.check === "auto" && rule.enforcer === undefined)
|
|
90
|
+
issue("enforcer", "an automatic rule names its enforcer");
|
|
91
|
+
if (rule.check !== "auto" && rule.enforcer !== undefined)
|
|
92
|
+
issue("check", "a rule with an enforcer is automatic");
|
|
93
|
+
if (rule.enforcer !== undefined && rule.examples === undefined) {
|
|
94
|
+
issue("examples", "an enforced rule has an example that must fail and one that must pass");
|
|
95
|
+
}
|
|
96
|
+
const settings = rule.enforcer?.tool === "eslint"
|
|
97
|
+
? rule.enforcer.options
|
|
98
|
+
: rule.enforcer?.tool === "ruff"
|
|
99
|
+
? rule.enforcer.settings
|
|
100
|
+
: undefined;
|
|
101
|
+
const usesValue = `${rule.title} ${rule.rule}`.includes("{value}") || JSON.stringify(settings ?? []).includes(VALUE);
|
|
102
|
+
if (usesValue && rule.default === undefined)
|
|
103
|
+
issue("default", "a rule that uses {value} has a default");
|
|
104
|
+
});
|
|
105
|
+
export const ProfileSchema = z.strictObject({
|
|
106
|
+
id: Slug.describe("How a project lists the profile in standards.profiles, such as react-native."),
|
|
107
|
+
name: Text,
|
|
108
|
+
prefix: z.string().regex(/^[A-Z][A-Z0-9]*$/, "capital letters and digits, such as REACT"),
|
|
109
|
+
about: Text.describe("What the profile covers, for its page."),
|
|
110
|
+
stacks: z
|
|
111
|
+
.array(Slug)
|
|
112
|
+
.describe("The stack tags it applies to, as detection writes them. None: every part, when listed."),
|
|
113
|
+
extends: z.array(Slug).optional().describe("Profiles it builds on, which apply wherever it does."),
|
|
114
|
+
rules: z.array(ProfileRuleSchema),
|
|
115
|
+
});
|
|
116
|
+
/**
|
|
117
|
+
* Checks every profile against the schema and against the core: prefixes and ids used once, each
|
|
118
|
+
* rule's id starting with its profile's prefix, each carried rule a core rule, and every profile
|
|
119
|
+
* it extends known, without a loop.
|
|
120
|
+
*/
|
|
121
|
+
export function checkProfiles(inputs, core, corePrefixes) {
|
|
122
|
+
const problems = [];
|
|
123
|
+
const profiles = [];
|
|
124
|
+
const coreById = new Map(core.map((rule) => [rule.id, rule]));
|
|
125
|
+
const ids = new Set();
|
|
126
|
+
const prefixes = new Set(corePrefixes);
|
|
127
|
+
const ruleIds = new Set();
|
|
128
|
+
for (const input of inputs) {
|
|
129
|
+
const result = ProfileSchema.safeParse(input);
|
|
130
|
+
if (!result.success) {
|
|
131
|
+
for (const issue of result.error.issues)
|
|
132
|
+
problems.push(`${input.id}: ${issue.path.join(".")}: ${issue.message}`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const profile = result.data;
|
|
136
|
+
if (ids.has(profile.id))
|
|
137
|
+
problems.push(`${profile.id}: the profile id is used twice`);
|
|
138
|
+
if (prefixes.has(profile.prefix))
|
|
139
|
+
problems.push(`${profile.id}: the prefix ${profile.prefix} is already used`);
|
|
140
|
+
ids.add(profile.id);
|
|
141
|
+
prefixes.add(profile.prefix);
|
|
142
|
+
const rules = [];
|
|
143
|
+
for (const rule of profile.rules) {
|
|
144
|
+
if (!rule.id.startsWith(`${profile.prefix}-`)) {
|
|
145
|
+
problems.push(`${rule.id}: a ${profile.id} rule's id starts with ${profile.prefix}-`);
|
|
146
|
+
}
|
|
147
|
+
if (ruleIds.has(rule.id))
|
|
148
|
+
problems.push(`${rule.id}: the id is used twice`);
|
|
149
|
+
ruleIds.add(rule.id);
|
|
150
|
+
const carried = coreById.get(rule.carries);
|
|
151
|
+
if (carried === undefined) {
|
|
152
|
+
problems.push(`${rule.id}: it carries ${rule.carries}, which isn't a core rule`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
rules.push({ ...rule, profile: profile.id, domain: carried.domain });
|
|
156
|
+
}
|
|
157
|
+
profiles.push({ ...profile, extends: profile.extends ?? [], rules });
|
|
158
|
+
}
|
|
159
|
+
const byId = new Map(profiles.map((profile) => [profile.id, profile]));
|
|
160
|
+
for (const profile of profiles) {
|
|
161
|
+
for (const base of profile.extends) {
|
|
162
|
+
if (!byId.has(base))
|
|
163
|
+
problems.push(`${profile.id}: it extends ${base}, which isn't a profile`);
|
|
164
|
+
}
|
|
165
|
+
if (reachable(byId, profile.extends).has(profile.id))
|
|
166
|
+
problems.push(`${profile.id}: it extends itself`);
|
|
167
|
+
}
|
|
168
|
+
return { profiles, problems };
|
|
169
|
+
}
|
|
170
|
+
/** Every profile the given ones extend, directly or through others. */
|
|
171
|
+
function reachable(byId, start) {
|
|
172
|
+
const seen = new Set();
|
|
173
|
+
const queue = [...start];
|
|
174
|
+
for (let id = queue.shift(); id !== undefined; id = queue.shift()) {
|
|
175
|
+
if (seen.has(id))
|
|
176
|
+
continue;
|
|
177
|
+
seen.add(id);
|
|
178
|
+
queue.push(...(byId.get(id)?.extends ?? []));
|
|
179
|
+
}
|
|
180
|
+
return seen;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The listed profiles that apply to a part, with the profiles they extend, bases first. A listed
|
|
184
|
+
* profile applies when the part's stack has one of its tags, when it names no stacks, or when the
|
|
185
|
+
* part names no stack. A profile it extends comes only with it, never on its own tag.
|
|
186
|
+
*/
|
|
187
|
+
export function profilesFor(all, listed, part) {
|
|
188
|
+
const byId = new Map(all.map((profile) => [profile.id, profile]));
|
|
189
|
+
const stack = part.stack ?? [];
|
|
190
|
+
const direct = listed.filter((id) => {
|
|
191
|
+
const profile = byId.get(id);
|
|
192
|
+
if (profile === undefined)
|
|
193
|
+
return false;
|
|
194
|
+
return stack.length === 0 || profile.stacks.length === 0 || profile.stacks.some((tag) => stack.includes(tag));
|
|
195
|
+
});
|
|
196
|
+
const applying = new Set([...direct, ...reachable(byId, direct)]);
|
|
197
|
+
const ordered = [];
|
|
198
|
+
const visit = (id) => {
|
|
199
|
+
const profile = byId.get(id);
|
|
200
|
+
if (profile === undefined || ordered.includes(profile) || !applying.has(id))
|
|
201
|
+
return;
|
|
202
|
+
for (const base of profile.extends)
|
|
203
|
+
visit(base);
|
|
204
|
+
ordered.push(profile);
|
|
205
|
+
};
|
|
206
|
+
for (const id of all.map((profile) => profile.id))
|
|
207
|
+
visit(id);
|
|
208
|
+
return ordered;
|
|
209
|
+
}
|
|
210
|
+
/** Puts the rule's value in its text, and in its enforcer's options. */
|
|
211
|
+
export function withValue(rule, value) {
|
|
212
|
+
if (value === undefined)
|
|
213
|
+
return rule;
|
|
214
|
+
const fillText = (text) => text.replaceAll("{value}", String(value));
|
|
215
|
+
const enforcer = rule.enforcer?.tool === "eslint" && rule.enforcer.options !== undefined
|
|
216
|
+
? { ...rule.enforcer, options: fillOptions(rule.enforcer.options, value) }
|
|
217
|
+
: rule.enforcer?.tool === "ruff" && rule.enforcer.settings !== undefined
|
|
218
|
+
? { ...rule.enforcer, settings: fill(rule.enforcer.settings, value) }
|
|
219
|
+
: rule.enforcer;
|
|
220
|
+
return {
|
|
221
|
+
...rule,
|
|
222
|
+
title: fillText(rule.title),
|
|
223
|
+
rule: fillText(rule.rule),
|
|
224
|
+
ask: fillText(rule.ask),
|
|
225
|
+
...(enforcer === undefined ? {} : { enforcer }),
|
|
226
|
+
value,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** The options or settings with the rule's value put where they say $value. */
|
|
230
|
+
function fill(option, value) {
|
|
231
|
+
if (option === VALUE)
|
|
232
|
+
return value;
|
|
233
|
+
if (Array.isArray(option))
|
|
234
|
+
return option.map((inner) => fill(inner, value));
|
|
235
|
+
if (option !== null && typeof option === "object") {
|
|
236
|
+
return Object.fromEntries(Object.entries(option).map(([key, inner]) => [key, fill(inner, value)]));
|
|
237
|
+
}
|
|
238
|
+
return option;
|
|
239
|
+
}
|
|
240
|
+
const fillOptions = (options, value) => fill(options, value);
|
|
241
|
+
/**
|
|
242
|
+
* The profile rules that apply to a part at the project's stage, with its traits and its
|
|
243
|
+
* architecture, and with the project's values in place of the defaults.
|
|
244
|
+
*/
|
|
245
|
+
export function applyProfiles(all, selection) {
|
|
246
|
+
const reached = STAGES.indexOf(selection.stage);
|
|
247
|
+
const traits = selection.traits ?? [];
|
|
248
|
+
return profilesFor(all, selection.listed, selection)
|
|
249
|
+
.flatMap((profile) => profile.rules)
|
|
250
|
+
.filter((rule) => STAGES.indexOf(rule.stage) <= reached &&
|
|
251
|
+
traitsNeeded(rule).every((trait) => traits.includes(trait)) &&
|
|
252
|
+
(rule.architectures === undefined ||
|
|
253
|
+
(selection.architecture !== undefined && rule.architectures.includes(selection.architecture))))
|
|
254
|
+
.map((rule) => {
|
|
255
|
+
const changed = selection.overrides?.[rule.id]?.value;
|
|
256
|
+
return withValue(rule, changed !== undefined && overrideFits(rule, changed) ? changed : rule.default?.value);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Whether a project's value can stand in for the rule's default: a rule with one, and a value of
|
|
261
|
+
* its type. A default that's a whole number, such as a count of lines, takes only whole numbers of
|
|
262
|
+
* 0 or more, since the tools refuse anything else.
|
|
263
|
+
*/
|
|
264
|
+
export function overrideFits(rule, value) {
|
|
265
|
+
if (rule.default === undefined || typeof value !== typeof rule.default.value)
|
|
266
|
+
return false;
|
|
267
|
+
const counts = typeof rule.default.value === "number" && Number.isInteger(rule.default.value);
|
|
268
|
+
return !counts || (Number.isInteger(value) && Number(value) >= 0);
|
|
269
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Express. It builds on Node. Its layering rules apply only where a part declares that
|
|
2
|
+
// architecture, so a modular monolith and a layered API each get their own. Examples are from a
|
|
3
|
+
// made-up bicycle repair booking service.
|
|
4
|
+
export const express = {
|
|
5
|
+
id: "express",
|
|
6
|
+
name: "Express",
|
|
7
|
+
prefix: "EXPRESS",
|
|
8
|
+
about: "APIs in Express: bodies with a limit, input checked at the route, one error handler that keeps internals in, security headers, and callers from a fixed list. Its layering rules follow the architecture a part declares. It builds on the Node profile.",
|
|
9
|
+
stacks: ["express"],
|
|
10
|
+
extends: ["node"],
|
|
11
|
+
rules: [
|
|
12
|
+
{
|
|
13
|
+
id: "EXPRESS-01",
|
|
14
|
+
title: "Request bodies stay under {value}",
|
|
15
|
+
rule: 'Body parsers keep a size limit, such as `express.json({ limit: "{value}" })`, raised only for a route that needs more, such as an upload.',
|
|
16
|
+
why: "A body with no limit lets one request fill the server's memory.",
|
|
17
|
+
ask: "Does every body parser in this change keep a size limit?",
|
|
18
|
+
stage: "mvp",
|
|
19
|
+
check: "ai-review",
|
|
20
|
+
severity: "medium",
|
|
21
|
+
carries: "BE-02",
|
|
22
|
+
default: { value: "100kb" },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "EXPRESS-02",
|
|
26
|
+
title: "Input is checked at the route",
|
|
27
|
+
rule: "Every route checks its body, parameters and query against a schema before anything else uses them.",
|
|
28
|
+
why: "Express passes on whatever arrives. Unchecked input is how wrong types, extra fields and injections reach the business rules.",
|
|
29
|
+
ask: "Does every route in this change check its input against a schema?",
|
|
30
|
+
stage: "prototype",
|
|
31
|
+
check: "ai-review",
|
|
32
|
+
severity: "high",
|
|
33
|
+
carries: "SEC-05",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "EXPRESS-03",
|
|
37
|
+
title: "One error handler, and no internals in its answer",
|
|
38
|
+
rule: "Errors reach one error handler, which answers in the project's error shape, without stack traces, queries or file paths.",
|
|
39
|
+
why: "Express's default answer to an error shows the stack trace outside production, and a missed setting shows it in production.",
|
|
40
|
+
ask: "Could an error in this change send internal detail to the client?",
|
|
41
|
+
stage: "mvp",
|
|
42
|
+
check: "ai-review",
|
|
43
|
+
severity: "medium",
|
|
44
|
+
carries: "SEC-09",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "EXPRESS-04",
|
|
48
|
+
title: "Every response sets its security headers",
|
|
49
|
+
rule: "The security headers are set for every response, such as with `helmet`, before any route.",
|
|
50
|
+
why: "Express sets none by default, so a browser gives its pages none of the protections the headers switch on.",
|
|
51
|
+
ask: "Are the security headers set before every route?",
|
|
52
|
+
stage: "mvp",
|
|
53
|
+
check: "ai-review",
|
|
54
|
+
severity: "medium",
|
|
55
|
+
carries: "SEC-17",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "EXPRESS-05",
|
|
59
|
+
title: "Cross-origin callers come from a fixed list",
|
|
60
|
+
rule: "CORS allows only a fixed list of the project's own origins. It never echoes back whatever origin asked, such as with `origin: true`, least of all with credentials.",
|
|
61
|
+
why: "Echoing the caller's origin with credentials lets any site call the API as the visitor.",
|
|
62
|
+
ask: "Does the CORS policy allow only a fixed list of origins?",
|
|
63
|
+
stage: "mvp",
|
|
64
|
+
check: "ai-review",
|
|
65
|
+
severity: "high",
|
|
66
|
+
carries: "SEC-16",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "EXPRESS-06",
|
|
70
|
+
title: "Routes call services, never the database",
|
|
71
|
+
rule: "In a layered API, a route reads the request, calls a service and answers. Only the data layer queries the database.",
|
|
72
|
+
why: "A route that queries the database skips the rules the service enforces, and the next change has to find every copy.",
|
|
73
|
+
ask: "Does any route in this change reach the database without its service?",
|
|
74
|
+
stage: "mvp",
|
|
75
|
+
check: "ai-review",
|
|
76
|
+
severity: "medium",
|
|
77
|
+
carries: "ARC-07",
|
|
78
|
+
architectures: ["layered"],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "EXPRESS-07",
|
|
82
|
+
title: "Modules use each other only through what they export",
|
|
83
|
+
rule: "In a modular monolith, each module's routes, services and data stay inside it. Another module imports only what the module's index exports.",
|
|
84
|
+
why: "Reaching into another module's files ties the two together, so neither can change alone.",
|
|
85
|
+
ask: "Does this change import another module's internal files?",
|
|
86
|
+
stage: "mvp",
|
|
87
|
+
check: "ai-review",
|
|
88
|
+
severity: "medium",
|
|
89
|
+
carries: "ARC-03",
|
|
90
|
+
architectures: ["modular-monolith"],
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// FastAPI. It builds on Python. Its layering rules apply only where a part declares that
|
|
2
|
+
// architecture, so a modular monolith and a layered API each get their own. Examples are from a
|
|
3
|
+
// made-up bicycle repair booking service.
|
|
4
|
+
export const fastapi = {
|
|
5
|
+
id: "python-fastapi",
|
|
6
|
+
name: "FastAPI",
|
|
7
|
+
prefix: "FASTAPI",
|
|
8
|
+
about: "APIs in FastAPI: models for what comes in and what goes out, permission checked per record, settings checked at start-up, callers from a fixed list, errors that keep internals in, and a body limit set somewhere. Its layering rules follow the architecture a part declares. It builds on the Python profile.",
|
|
9
|
+
stacks: ["fastapi"],
|
|
10
|
+
extends: ["python"],
|
|
11
|
+
rules: [
|
|
12
|
+
{
|
|
13
|
+
id: "FASTAPI-01",
|
|
14
|
+
title: "Every route has models for what comes in and goes out",
|
|
15
|
+
rule: "Every route takes its body as a Pydantic model and declares its response model, so FastAPI checks what arrives and sends only the fields the response names.",
|
|
16
|
+
why: "A route that returns a database object sends every field it has, including ones the client should never see.",
|
|
17
|
+
ask: "Does every route in this change declare request and response models?",
|
|
18
|
+
stage: "prototype",
|
|
19
|
+
check: "ai-review",
|
|
20
|
+
severity: "high",
|
|
21
|
+
carries: "SEC-05",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "FASTAPI-02",
|
|
25
|
+
title: "Permission is checked for the record, not only the person",
|
|
26
|
+
rule: "A route that takes an id checks the caller may use that record, such as in a dependency that loads it for the caller, not only that they're signed in.",
|
|
27
|
+
why: "A dependency that only checks sign-in lets anyone signed in change an id in the URL and read someone else's record.",
|
|
28
|
+
ask: "Does every route in this change that takes an id check the caller may use that record?",
|
|
29
|
+
stage: "mvp",
|
|
30
|
+
check: "ai-review",
|
|
31
|
+
severity: "critical",
|
|
32
|
+
carries: "SEC-01",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "FASTAPI-03",
|
|
36
|
+
title: "Settings are checked when the app starts",
|
|
37
|
+
rule: "Settings are a Pydantic settings class, loaded once at start-up, so the app refuses to start with a setting missing or wrong, and an environment name it doesn't know is treated as production.",
|
|
38
|
+
why: "A setting read with os.environ where it's used fails the first request that needs it, long after the deploy looked fine.",
|
|
39
|
+
ask: "Does this change read a setting outside the settings class?",
|
|
40
|
+
stage: "mvp",
|
|
41
|
+
check: "ai-review",
|
|
42
|
+
severity: "medium",
|
|
43
|
+
carries: "REL-04",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "FASTAPI-04",
|
|
47
|
+
title: "Cross-origin callers come from a fixed list",
|
|
48
|
+
rule: "`CORSMiddleware` allows only a fixed list of the project's own origins, never `*` with credentials.",
|
|
49
|
+
why: "An open CORS policy lets any site call the API with the visitor's credentials.",
|
|
50
|
+
ask: "Does the CORS middleware allow only a fixed list of origins?",
|
|
51
|
+
stage: "mvp",
|
|
52
|
+
check: "ai-review",
|
|
53
|
+
severity: "high",
|
|
54
|
+
carries: "SEC-16",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "FASTAPI-05",
|
|
58
|
+
title: "Errors keep internals in",
|
|
59
|
+
rule: "Exception handlers answer in the project's error shape, without tracebacks, queries or driver errors, and `debug` is off outside development.",
|
|
60
|
+
why: "With debug on, or a handler that passes on an exception's text, such as `HTTPException(detail=str(error))`, a database error reaches the client with the query and the table names in it.",
|
|
61
|
+
ask: "Could an error in this change reach the client with internal detail?",
|
|
62
|
+
stage: "mvp",
|
|
63
|
+
check: "ai-review",
|
|
64
|
+
severity: "medium",
|
|
65
|
+
carries: "SEC-09",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "FASTAPI-06",
|
|
69
|
+
title: "Request bodies stay under {value}",
|
|
70
|
+
rule: "Something limits request bodies to {value}, such as the proxy in front or a middleware like Starlette's `RequestBodyLimitMiddleware`, since FastAPI sets no limit itself.",
|
|
71
|
+
why: "With no limit, one request can fill the server's memory.",
|
|
72
|
+
ask: "What limits the size of a request body, and is it {value} or less?",
|
|
73
|
+
stage: "mvp",
|
|
74
|
+
check: "ai-review",
|
|
75
|
+
severity: "medium",
|
|
76
|
+
carries: "BE-02",
|
|
77
|
+
default: { value: "1 MiB" },
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: "FASTAPI-07",
|
|
81
|
+
title: "Routes call services, never the session",
|
|
82
|
+
rule: "In a layered API, a route reads the request, calls a service and answers. Only the data layer uses the database session.",
|
|
83
|
+
why: "A route that queries the session skips the rules the service enforces, and the next change has to find every copy.",
|
|
84
|
+
ask: "Does any route in this change use the database session directly?",
|
|
85
|
+
stage: "mvp",
|
|
86
|
+
check: "ai-review",
|
|
87
|
+
severity: "medium",
|
|
88
|
+
carries: "ARC-07",
|
|
89
|
+
architectures: ["layered"],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "FASTAPI-08",
|
|
93
|
+
title: "Each domain keeps its router, services and models to itself",
|
|
94
|
+
rule: "In a modular monolith, each domain's package holds its router, services and models. Another domain imports only what the package's public module exports.",
|
|
95
|
+
why: "Reaching into another domain's models or services ties the two together, so neither can change alone.",
|
|
96
|
+
ask: "Does this change import another domain's internal modules?",
|
|
97
|
+
stage: "mvp",
|
|
98
|
+
check: "ai-review",
|
|
99
|
+
severity: "medium",
|
|
100
|
+
carries: "ARC-03",
|
|
101
|
+
architectures: ["modular-monolith"],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Fastify. It builds on Node. Examples are from a made-up bicycle repair booking service.
|
|
2
|
+
export const fastify = {
|
|
3
|
+
id: "fastify",
|
|
4
|
+
name: "Fastify",
|
|
5
|
+
prefix: "FASTIFY",
|
|
6
|
+
about: "APIs in Fastify: a schema on every route, for what comes in and what goes out, a body limit chosen on purpose, one error handler, and plugins that keep to themselves. It builds on the Node profile.",
|
|
7
|
+
stacks: ["fastify"],
|
|
8
|
+
extends: ["node"],
|
|
9
|
+
rules: [
|
|
10
|
+
{
|
|
11
|
+
id: "FASTIFY-01",
|
|
12
|
+
title: "Every route has a schema, in and out",
|
|
13
|
+
rule: "Every route declares schemas for its body, parameters, query and responses, so Fastify checks what comes in and sends only the fields the response schema names.",
|
|
14
|
+
why: "Without a response schema, Fastify sends whatever the handler returns, including fields the client should never see.",
|
|
15
|
+
ask: "Does every route in this change declare its request and response schemas?",
|
|
16
|
+
stage: "prototype",
|
|
17
|
+
check: "ai-review",
|
|
18
|
+
severity: "high",
|
|
19
|
+
carries: "SEC-05",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "FASTIFY-02",
|
|
23
|
+
title: "Request bodies stay under {value}",
|
|
24
|
+
rule: "`bodyLimit` stays at {value} or below, raised only for a route that needs more, such as an upload.",
|
|
25
|
+
why: "Raising the limit for everything lets one request fill the server's memory.",
|
|
26
|
+
ask: "Does this change raise the body limit beyond {value}, or for more than the routes that need it?",
|
|
27
|
+
stage: "mvp",
|
|
28
|
+
check: "ai-review",
|
|
29
|
+
severity: "medium",
|
|
30
|
+
carries: "BE-02",
|
|
31
|
+
default: { value: "1 MiB" },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "FASTIFY-03",
|
|
35
|
+
title: "One error handler keeps internals in",
|
|
36
|
+
rule: "`setErrorHandler` answers every error in the project's error shape, with a generic message for anything unexpected, and the detail kept for the log.",
|
|
37
|
+
why: "Fastify's default handler sends the error's own message, even on a 500, which can carry query text or table names.",
|
|
38
|
+
ask: "Could an error in this change reach the client with its own message?",
|
|
39
|
+
stage: "mvp",
|
|
40
|
+
check: "ai-review",
|
|
41
|
+
severity: "medium",
|
|
42
|
+
carries: "SEC-09",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "FASTIFY-04",
|
|
46
|
+
title: "Plugins keep what they add to themselves",
|
|
47
|
+
rule: "A plugin's decorators and hooks stay inside it. One is shared with `fastify-plugin` only when the whole app needs it, and says so.",
|
|
48
|
+
why: "Sharing everything by default makes each plugin depend on the others' insides, so none can change alone.",
|
|
49
|
+
ask: "Does this change share a plugin's decorators or hooks beyond the code that needs them?",
|
|
50
|
+
stage: "mvp",
|
|
51
|
+
check: "ai-review",
|
|
52
|
+
severity: "low",
|
|
53
|
+
carries: "ARC-03",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|