@facilis.dev/ai 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/dist/context.d.ts +12 -0
  4. package/dist/context.js +104 -0
  5. package/dist/index.d.ts +16 -0
  6. package/dist/index.js +15 -0
  7. package/dist/manifest-facts.d.ts +16 -0
  8. package/dist/manifest-facts.js +45 -0
  9. package/dist/node.d.ts +19 -0
  10. package/dist/node.js +60 -0
  11. package/dist/overlay.d.ts +41 -0
  12. package/dist/overlay.js +91 -0
  13. package/dist/paths.d.ts +41 -0
  14. package/dist/paths.js +70 -0
  15. package/dist/rules/blocks.d.ts +12 -0
  16. package/dist/rules/blocks.js +64 -0
  17. package/dist/rules/config-sync.d.ts +9 -0
  18. package/dist/rules/config-sync.js +64 -0
  19. package/dist/rules/database.d.ts +24 -0
  20. package/dist/rules/database.js +105 -0
  21. package/dist/rules/i18n.d.ts +9 -0
  22. package/dist/rules/i18n.js +40 -0
  23. package/dist/rules/imports.d.ts +15 -0
  24. package/dist/rules/imports.js +194 -0
  25. package/dist/rules/job-relations.d.ts +23 -0
  26. package/dist/rules/job-relations.js +84 -0
  27. package/dist/rules/route-relations.d.ts +20 -0
  28. package/dist/rules/route-relations.js +113 -0
  29. package/dist/rules/routes-description.d.ts +14 -0
  30. package/dist/rules/routes-description.js +86 -0
  31. package/dist/rules/routes-metadata.d.ts +16 -0
  32. package/dist/rules/routes-metadata.js +169 -0
  33. package/dist/rules/routes-security.d.ts +22 -0
  34. package/dist/rules/routes-security.js +52 -0
  35. package/dist/rules/structural.d.ts +16 -0
  36. package/dist/rules/structural.js +41 -0
  37. package/dist/rules/ts-parse.d.ts +16 -0
  38. package/dist/rules/ts-parse.js +62 -0
  39. package/dist/syntax-runner.d.ts +8 -0
  40. package/dist/syntax-runner.js +25 -0
  41. package/dist/testing/fixtures.d.ts +48 -0
  42. package/dist/testing/fixtures.js +153 -0
  43. package/dist/types.d.ts +146 -0
  44. package/dist/types.js +12 -0
  45. package/dist/validator.d.ts +13 -0
  46. package/dist/validator.js +62 -0
  47. package/package.json +43 -4
  48. package/src/context.ts +117 -0
  49. package/src/index.ts +44 -0
  50. package/src/manifest-facts.ts +54 -0
  51. package/src/node.ts +75 -0
  52. package/src/overlay.ts +131 -0
  53. package/src/paths.ts +80 -0
  54. package/src/rules/blocks.ts +67 -0
  55. package/src/rules/config-sync.ts +72 -0
  56. package/src/rules/database.ts +139 -0
  57. package/src/rules/i18n.ts +42 -0
  58. package/src/rules/imports.ts +227 -0
  59. package/src/rules/job-relations.ts +126 -0
  60. package/src/rules/route-relations.ts +162 -0
  61. package/src/rules/routes-description.ts +113 -0
  62. package/src/rules/routes-metadata.ts +241 -0
  63. package/src/rules/routes-security.ts +90 -0
  64. package/src/rules/structural.ts +83 -0
  65. package/src/rules/ts-parse.ts +71 -0
  66. package/src/syntax-runner.ts +33 -0
  67. package/src/testing/fixtures.ts +215 -0
  68. package/src/types.ts +179 -0
  69. package/src/validator.ts +81 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Rules 9 `no-raw-db-access` and 12 `import-resolves` (SPEC §9.3).
3
+ *
4
+ * Rule 9 keeps raw database clients inside `@facilis.dev/db` (invariant 5); drizzle operators
5
+ * and `drizzle-orm/pg-core` are the schema DSL and stay allowed. Rule 12 checks that every
6
+ * module reference resolves against the effective file set, the project's aliases and the
7
+ * packages package.json declares — no filesystem, no node_modules walk.
8
+ *
9
+ * Both read the analyzer's ImportFacts, so static imports, re-exports, dynamic imports and
10
+ * `require()` share the same AST truth.
11
+ */
12
+
13
+ import type { EffectiveState } from "../overlay.ts";
14
+ import { normalizeProjectPath } from "../paths.ts";
15
+ import type { ImportAlias, ImportFact, PatchContext, ValidationError } from "../types.ts";
16
+
17
+ const RAW_DRIVER =
18
+ /^drizzle-orm\/(?:pglite|node-postgres|postgres-js|bun-sqlite|neon|vercel-postgres|aws-data-api|libsql|expo-sqlite|d1|op-sqlite|singlestore|tidb-serverless)$/;
19
+ const RAW_CLIENTS = new Set(["@electric-sql/pglite", "postgres", "pg"]);
20
+
21
+ const NODE_BUILTINS = new Set([
22
+ "assert",
23
+ "async_hooks",
24
+ "buffer",
25
+ "child_process",
26
+ "cluster",
27
+ "console",
28
+ "constants",
29
+ "crypto",
30
+ "dgram",
31
+ "diagnostics_channel",
32
+ "dns",
33
+ "domain",
34
+ "events",
35
+ "fs",
36
+ "http",
37
+ "http2",
38
+ "https",
39
+ "inspector",
40
+ "module",
41
+ "net",
42
+ "os",
43
+ "path",
44
+ "perf_hooks",
45
+ "process",
46
+ "punycode",
47
+ "querystring",
48
+ "readline",
49
+ "repl",
50
+ "stream",
51
+ "string_decoder",
52
+ "sys",
53
+ "timers",
54
+ "tls",
55
+ "trace_events",
56
+ "tty",
57
+ "url",
58
+ "util",
59
+ "v8",
60
+ "vm",
61
+ "wasi",
62
+ "worker_threads",
63
+ "zlib",
64
+ ]);
65
+
66
+ const RESOLVABLE_EXTENSIONS = [
67
+ ".ts",
68
+ ".tsx",
69
+ ".js",
70
+ ".jsx",
71
+ ".mjs",
72
+ ".cjs",
73
+ ".json",
74
+ ".css",
75
+ ".svg",
76
+ ];
77
+ const HAS_EXTENSION = /\.[A-Za-z0-9]+$/;
78
+
79
+ function error(
80
+ rule: string,
81
+ file: string,
82
+ message: string,
83
+ fix_hint: string,
84
+ line?: number,
85
+ ): ValidationError {
86
+ return {
87
+ rule,
88
+ severity: "error",
89
+ file,
90
+ message,
91
+ fix_hint,
92
+ ...(line === undefined ? {} : { line }),
93
+ };
94
+ }
95
+
96
+ /** Imports deduped per statement: a named-import clause produces one fact per name. */
97
+ function statementImports(imports: readonly ImportFact[]): ImportFact[] {
98
+ const seen = new Set<string>();
99
+ const unique: ImportFact[] = [];
100
+ for (const imported of imports) {
101
+ const key = imported.specifier + "\u0000" + imported.source.line;
102
+ if (seen.has(key)) continue;
103
+ seen.add(key);
104
+ unique.push(imported);
105
+ }
106
+ return unique;
107
+ }
108
+
109
+ export function rawDbAccessErrors(state: EffectiveState): ValidationError[] {
110
+ const errors: ValidationError[] = [];
111
+ for (const facts of state.patchedFileFacts) {
112
+ const imports = statementImports(facts.imports);
113
+ for (const imported of imports) {
114
+ if (!RAW_DRIVER.test(imported.specifier) && !RAW_CLIENTS.has(imported.specifier)) continue;
115
+ errors.push(
116
+ error(
117
+ "no-raw-db-access",
118
+ facts.path,
119
+ 'Importing "' + imported.specifier + '" is a raw database client outside @facilis.dev/db.',
120
+ "Go through the policy-scoped db the handler receives (invariant 5).",
121
+ imported.source.line,
122
+ ),
123
+ );
124
+ }
125
+ const rawDrizzleImport = imports.find((imported) => imported.specifier === "drizzle-orm");
126
+ if (rawDrizzleImport !== undefined && facts.calls.some((call) => call.name === "drizzle")) {
127
+ errors.push(
128
+ error(
129
+ "no-raw-db-access",
130
+ facts.path,
131
+ "Constructing a raw drizzle() client is not allowed outside @facilis.dev/db.",
132
+ "Use the policy-scoped db from the handler context; import operators from drizzle-orm instead (invariant 5).",
133
+ rawDrizzleImport.source.line,
134
+ ),
135
+ );
136
+ }
137
+ }
138
+ return errors;
139
+ }
140
+
141
+ function fileExists(files: ReadonlySet<string>, base: string): boolean {
142
+ const target = normalizeProjectPath(base);
143
+ if (target.length === 0) return false;
144
+ if (files.has(target)) return true;
145
+ if (HAS_EXTENSION.test(target)) return false;
146
+ for (const extension of RESOLVABLE_EXTENSIONS) {
147
+ if (files.has(target + extension)) return true;
148
+ if (files.has(target + "/index" + extension)) return true;
149
+ }
150
+ return false;
151
+ }
152
+
153
+ function relativeBase(fromPath: string, specifier: string): string {
154
+ const directory = fromPath.split("/").slice(0, -1);
155
+ const segments = [...directory, ...specifier.split("/")];
156
+ const resolved: string[] = [];
157
+ for (const segment of segments) {
158
+ if (segment.length === 0 || segment === ".") continue;
159
+ if (segment === "..") {
160
+ resolved.pop();
161
+ continue;
162
+ }
163
+ resolved.push(segment);
164
+ }
165
+ return resolved.join("/");
166
+ }
167
+
168
+ function aliasBase(specifier: string, aliases: readonly ImportAlias[]): string | null {
169
+ for (const alias of aliases) {
170
+ if (!specifier.startsWith(alias.prefix)) continue;
171
+ return normalizeProjectPath(alias.target + specifier.slice(alias.prefix.length));
172
+ }
173
+ return null;
174
+ }
175
+
176
+ function stripQuery(specifier: string): string {
177
+ const cut = specifier.search(/[?#]/);
178
+ return cut === -1 ? specifier : specifier.slice(0, cut);
179
+ }
180
+
181
+ function packageNameOf(specifier: string): string {
182
+ const parts = specifier.split("/");
183
+ if (specifier.startsWith("@")) return parts.slice(0, 2).join("/");
184
+ return parts[0] ?? specifier;
185
+ }
186
+
187
+ export function importResolveErrors(
188
+ state: EffectiveState,
189
+ context: PatchContext,
190
+ ): ValidationError[] {
191
+ const errors: ValidationError[] = [];
192
+ for (const facts of state.patchedFileFacts) {
193
+ for (const imported of statementImports(facts.imports)) {
194
+ const specifier = stripQuery(imported.specifier);
195
+ if (specifier.length === 0) continue;
196
+ if (specifier.startsWith("node:") || NODE_BUILTINS.has(specifier)) continue;
197
+ if (/^[a-z][a-z0-9+.-]*:/i.test(specifier)) continue;
198
+ const relative =
199
+ specifier.startsWith("./") || specifier.startsWith("../") || specifier.startsWith("/");
200
+ if (relative) {
201
+ const base = specifier.startsWith("/")
202
+ ? specifier.replace(/^\/+/, "")
203
+ : relativeBase(facts.path, specifier);
204
+ if (fileExists(state.files, base)) continue;
205
+ } else {
206
+ const alias = aliasBase(specifier, context.aliases);
207
+ if (alias !== null) {
208
+ if (fileExists(state.files, alias)) continue;
209
+ } else if (context.packages.includes(packageNameOf(specifier))) {
210
+ continue;
211
+ }
212
+ }
213
+ errors.push(
214
+ error(
215
+ "import-resolves",
216
+ facts.path,
217
+ 'Import "' + imported.specifier + '" does not resolve.',
218
+ relative
219
+ ? "Point the import at a file that exists (or is created by this patch)."
220
+ : "Use a package declared in package.json; new dependencies need user approval.",
221
+ imported.source.line,
222
+ ),
223
+ );
224
+ }
225
+ }
226
+ return errors;
227
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Rule 19 `job-table-declared` (SPEC §9.3, D-157 applied to jobs): a job's `dbTables` is an
3
+ * applied declaration.
4
+ *
5
+ * A job's `run(ctx)` always receives the system-scoped client (D-107), so its usage has no
6
+ * elevation dimension: every relation it reaches — tables and views — must be declared,
7
+ * following project-local helper calls, and a missing or unreadable declaration fails closed.
8
+ * The rule covers **all** effective jobs whose usage facts exist, not only the patched ones: a
9
+ * patch that edits a shared helper changes what untouched jobs touch, and must not bypass the
10
+ * declaration.
11
+ *
12
+ * Errors: undeclared, unresolved/dynamic relation, `db.execute` without `rawSql: true` and a
13
+ * declaration that cannot be read. Warning: declared but unused (suppressed when usage is
14
+ * opaque or raw SQL is present, where the declaration stays authoritative).
15
+ */
16
+
17
+ import type { EffectiveState } from "../overlay.ts";
18
+ import type { JobRelationsFact, ValidationError } from "../types.ts";
19
+
20
+ function lineOf(source: { readonly line: number } | undefined): number | undefined {
21
+ return source?.line;
22
+ }
23
+
24
+ function error(
25
+ file: string,
26
+ message: string,
27
+ fixHint: string,
28
+ line: number | undefined,
29
+ ): ValidationError {
30
+ return {
31
+ rule: "job-table-declared",
32
+ severity: "error",
33
+ file,
34
+ message,
35
+ fix_hint: fixHint,
36
+ ...(line === undefined ? {} : { line }),
37
+ };
38
+ }
39
+
40
+ function unusedWarning(job: { readonly sourceFile: string }, name: string): ValidationError {
41
+ return {
42
+ rule: "job-table-declared",
43
+ severity: "warning",
44
+ file: job.sourceFile,
45
+ message: 'dbTables declares "' + name + '" but the job never uses it.',
46
+ fix_hint:
47
+ 'Remove "' + name + '" from dbTables, or use it: the declaration is enforced (D-157).',
48
+ };
49
+ }
50
+
51
+ /** Opaque usage or raw SQL keeps the declaration authoritative instead of warning. */
52
+ function unusedSuppressed(usage: JobRelationsFact): boolean {
53
+ return usage.opaque || usage.rawSql.length > 0;
54
+ }
55
+
56
+ export interface JobTableDeclaredFindings {
57
+ readonly errors: ValidationError[];
58
+ readonly warnings: ValidationError[];
59
+ }
60
+
61
+ /** Rule 19 over the effective analysis (SPEC §9.3, D-157 applied to jobs). */
62
+ export function jobTableDeclaredFindings(state: EffectiveState): JobTableDeclaredFindings {
63
+ const errors: ValidationError[] = [];
64
+ const warnings: ValidationError[] = [];
65
+ for (const job of state.analysis.jobs) {
66
+ // A hand-built analysis may predate the fact: no usage facts, no check.
67
+ const usage: JobRelationsFact | undefined = state.analysis.jobRelations[job.name];
68
+ if (usage === undefined) continue;
69
+ const declared = job.dbTables === null ? null : new Set(job.dbTables);
70
+ if (declared === null) {
71
+ errors.push(
72
+ error(
73
+ job.sourceFile,
74
+ 'Job "' + job.name + '" dbTables is missing or cannot be read statically.',
75
+ "Declare dbTables on defineJob({...}): every table or view the run body touches ([] means none).",
76
+ lineOf(job.source),
77
+ ),
78
+ );
79
+ } else {
80
+ for (const use of usage.used) {
81
+ if (declared.has(use.name)) continue;
82
+ errors.push(
83
+ error(
84
+ use.source.file,
85
+ 'Relation "' + use.name + '" is used through the job db but not declared in dbTables.',
86
+ 'Add "' +
87
+ use.name +
88
+ '" to dbTables (tables and views the run body touches), or stop using it.',
89
+ lineOf(use.source),
90
+ ),
91
+ );
92
+ }
93
+ }
94
+ for (const source of usage.unresolved) {
95
+ errors.push(
96
+ error(
97
+ source.file,
98
+ "A relation used through the job db cannot be resolved statically.",
99
+ "Use a table or view binding imported from db/ instead of a dynamic expression.",
100
+ lineOf(source),
101
+ ),
102
+ );
103
+ }
104
+ for (const source of usage.rawSql) {
105
+ if (job.rawSql === true) continue;
106
+ errors.push(
107
+ error(
108
+ source.file,
109
+ "db.execute() requires rawSql: true on the job definition.",
110
+ job.rawSql === null
111
+ ? "Declare rawSql: true (or false) statically, or use the query builder."
112
+ : "Add rawSql: true to defineJob({...}), or use the query builder.",
113
+ lineOf(source),
114
+ ),
115
+ );
116
+ }
117
+ if (declared !== null && !unusedSuppressed(usage)) {
118
+ const used = new Set(usage.used.map((use) => use.name));
119
+ for (const name of job.dbTables ?? []) {
120
+ if (used.has(name)) continue;
121
+ warnings.push(unusedWarning(job, name));
122
+ }
123
+ }
124
+ }
125
+ return { errors, warnings };
126
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Rule 18 `route-table-declared` (SPEC §9.3, D-157): `dbTables` is an applied declaration.
3
+ *
4
+ * Every relation a handler reaches through `ctx.db`, `ctx.sudo` or `ctx.system` — tables
5
+ * and views — must be declared, following project-local helper calls. The rule covers **all**
6
+ * effective routes whose usage facts exist, not only the patched ones: a patch that edits a
7
+ * shared helper changes what untouched routes touch, and must not bypass the declaration.
8
+ *
9
+ * Errors: undeclared, unresolved/dynamic relation, `db.execute` without `rawSql: true`,
10
+ * elevation use outside its `tables` option. Warning: declared but unused (suppressed when
11
+ * usage is opaque or raw SQL is present, where the declaration stays authoritative).
12
+ */
13
+
14
+ import type { EffectiveState } from "../overlay.ts";
15
+ import type { RouteRelationsFact, ValidationError } from "../types.ts";
16
+
17
+ function lineOf(source: { readonly line: number } | undefined): number | undefined {
18
+ return source?.line;
19
+ }
20
+
21
+ function error(
22
+ file: string,
23
+ message: string,
24
+ fixHint: string,
25
+ line: number | undefined,
26
+ ): ValidationError {
27
+ return {
28
+ rule: "route-table-declared",
29
+ severity: "error",
30
+ file,
31
+ message,
32
+ fix_hint: fixHint,
33
+ ...(line === undefined ? {} : { line }),
34
+ };
35
+ }
36
+
37
+ function unusedWarning(route: { readonly sourceFile: string }, name: string): ValidationError {
38
+ return {
39
+ rule: "route-table-declared",
40
+ severity: "warning",
41
+ file: route.sourceFile,
42
+ message: 'dbTables declares "' + name + '" but the handler never uses it.',
43
+ fix_hint:
44
+ 'Remove "' + name + '" from dbTables, or use it: the declaration is enforced (D-157).',
45
+ };
46
+ }
47
+
48
+ function usedNames(usage: RouteRelationsFact): ReadonlySet<string> {
49
+ const names = new Set(usage.used.map((use) => use.name));
50
+ for (const elevation of usage.elevations) {
51
+ for (const use of elevation.used) names.add(use.name);
52
+ }
53
+ return names;
54
+ }
55
+
56
+ /** Opaque usage or raw SQL keeps the declaration authoritative instead of warning. */
57
+ function unusedSuppressed(usage: RouteRelationsFact): boolean {
58
+ return (
59
+ usage.opaque ||
60
+ usage.rawSql.length > 0 ||
61
+ usage.elevations.some((elevation) => elevation.opaque || elevation.tablesUnknown)
62
+ );
63
+ }
64
+
65
+ export interface RouteTableDeclaredFindings {
66
+ readonly errors: ValidationError[];
67
+ readonly warnings: ValidationError[];
68
+ }
69
+
70
+ /** Rule 18 over the effective analysis (SPEC §9.3, D-157). */
71
+ export function routeTableDeclaredFindings(state: EffectiveState): RouteTableDeclaredFindings {
72
+ const errors: ValidationError[] = [];
73
+ const warnings: ValidationError[] = [];
74
+ for (const route of state.analysis.routes) {
75
+ // A hand-built analysis may predate the fact (IDE snapshots): no usage facts, no check.
76
+ const usage: RouteRelationsFact | undefined = route.relations;
77
+ if (route.wrapper !== "valid" || usage === undefined) continue;
78
+ const declared = route.dbTables === null ? null : new Set(route.dbTables);
79
+ if (declared !== null) {
80
+ for (const use of usage.used) {
81
+ if (declared.has(use.name)) continue;
82
+ errors.push(
83
+ error(
84
+ use.source.file,
85
+ 'Relation "' +
86
+ use.name +
87
+ '" is used through the route db but not declared in dbTables.',
88
+ 'Add "' +
89
+ use.name +
90
+ '" to dbTables (tables and views the handler touches), or stop using it.',
91
+ lineOf(use.source),
92
+ ),
93
+ );
94
+ }
95
+ for (const elevation of usage.elevations) {
96
+ for (const use of elevation.used) {
97
+ if (declared.has(use.name)) continue;
98
+ errors.push(
99
+ error(
100
+ use.source.file,
101
+ 'Relation "' +
102
+ use.name +
103
+ '" is used inside an elevation but not declared in dbTables.',
104
+ 'Add "' + use.name + '" to dbTables and to the elevation tables option.',
105
+ lineOf(use.source),
106
+ ),
107
+ );
108
+ }
109
+ }
110
+ }
111
+ for (const source of usage.unresolved) {
112
+ errors.push(
113
+ error(
114
+ source.file,
115
+ "A relation used through the route db cannot be resolved statically.",
116
+ "Use a table or view binding imported from db/ instead of a dynamic expression.",
117
+ lineOf(source),
118
+ ),
119
+ );
120
+ }
121
+ for (const source of usage.rawSql) {
122
+ if (route.rawSql === true) continue;
123
+ errors.push(
124
+ error(
125
+ source.file,
126
+ "db.execute() requires rawSql: true on the route config.",
127
+ route.rawSql === null
128
+ ? "Declare rawSql: true (or false) statically, or use the query builder."
129
+ : "Add rawSql: true to the route config, or use the query builder.",
130
+ lineOf(source),
131
+ ),
132
+ );
133
+ }
134
+ for (const elevation of usage.elevations) {
135
+ if (elevation.tablesUnknown || elevation.opaque) continue;
136
+ const tables = new Set(elevation.tables);
137
+ for (const use of elevation.used) {
138
+ if (tables.has(use.name)) continue;
139
+ errors.push(
140
+ error(
141
+ use.source.file,
142
+ 'Relation "' +
143
+ use.name +
144
+ '" is used inside the "' +
145
+ elevation.kind +
146
+ '" elevation but not declared in its tables option.',
147
+ 'Add "' + use.name + '" to the elevation tables and to dbTables.',
148
+ lineOf(use.source),
149
+ ),
150
+ );
151
+ }
152
+ }
153
+ if (declared !== null && !unusedSuppressed(usage)) {
154
+ const used = usedNames(usage);
155
+ for (const name of route.dbTables ?? []) {
156
+ if (used.has(name)) continue;
157
+ warnings.push(unusedWarning(route, name));
158
+ }
159
+ }
160
+ }
161
+ return { errors, warnings };
162
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Rule 17 `route-description` (SPEC §9.3, D-129), a warning: the description is source
3
+ * documentation the project graph and the OpenAPI document show, so a missing, over-long or
4
+ * clearly stale one is worth fixing — but it must never stop a patch from applying.
5
+ *
6
+ * "Stale" is deliberately narrow: the text is unchanged while the route's contract changed
7
+ * in the same patch. Only then is the description demonstrably out of date rather than just
8
+ * terse, which a text check cannot tell. A route with an invalid wrapper is skipped because
9
+ * rule 2 already blocks it.
10
+ */
11
+
12
+ import { ROUTE_DESCRIPTION_MAX_CHARS } from "@facilis.dev/analyze";
13
+
14
+ import type { EffectiveState } from "../overlay.ts";
15
+ import { normalizeProjectPath } from "../paths.ts";
16
+ import type { RouteFact, ValidationError } from "../types.ts";
17
+ import { patchedRoutes } from "./routes-security.ts";
18
+
19
+ const RULE = "route-description";
20
+
21
+ function warning(route: RouteFact, message: string, fix_hint: string): ValidationError {
22
+ return {
23
+ rule: RULE,
24
+ severity: "warning",
25
+ file: route.sourceFile,
26
+ message,
27
+ fix_hint,
28
+ line: route.source.line,
29
+ };
30
+ }
31
+
32
+ /** A contract list compared order-insensitively; null (unknown) stays distinct from []. */
33
+ function stableList(values: readonly string[] | null): string | null {
34
+ return values === null ? null : [...values].sort().join("\u0000");
35
+ }
36
+
37
+ /**
38
+ * The contract fields D-129 says the description must cover. The lists are sorted because
39
+ * `permission`, `dbTables` and `dbColumns` are sets: reordering them changes no behaviour.
40
+ */
41
+ function contractSignature(route: RouteFact): string {
42
+ return JSON.stringify([
43
+ route.wrapper,
44
+ stableList(route.permission),
45
+ stableList(route.dbTables),
46
+ stableList(route.dbColumns),
47
+ route.input.body,
48
+ route.input.form,
49
+ route.input.query,
50
+ route.input.params,
51
+ ]);
52
+ }
53
+
54
+ /** The pre-patch fact of the very same route, matched by file + method + path. */
55
+ function prePatchRoute(state: EffectiveState, route: RouteFact): RouteFact | undefined {
56
+ const file = normalizeProjectPath(route.sourceFile);
57
+ return state.before.routes.find(
58
+ (candidate) =>
59
+ normalizeProjectPath(candidate.sourceFile) === file &&
60
+ candidate.method === route.method &&
61
+ candidate.path === route.path,
62
+ );
63
+ }
64
+
65
+ /** Rule 17: every patched route should carry one current English description. */
66
+ export function routeDescriptionWarnings(state: EffectiveState): ValidationError[] {
67
+ const warnings: ValidationError[] = [];
68
+ for (const route of patchedRoutes(state)) {
69
+ // Rule 2 reports the wrapper error; a description for a broken route is not actionable.
70
+ if (route.wrapper !== "valid") continue;
71
+ const description = route.description;
72
+ if (description === null || description.trim() === "") {
73
+ warnings.push(
74
+ warning(
75
+ route,
76
+ "Route config must declare a description.",
77
+ "Add a one-sentence description of what the route does, in English, max " +
78
+ ROUTE_DESCRIPTION_MAX_CHARS +
79
+ " characters.",
80
+ ),
81
+ );
82
+ // Without text it cannot be over-long or stale (and the warning above already fires).
83
+ continue;
84
+ }
85
+ if (description.length > ROUTE_DESCRIPTION_MAX_CHARS) {
86
+ warnings.push(
87
+ warning(
88
+ route,
89
+ "The route description is " +
90
+ description.length +
91
+ " characters; keep it to " +
92
+ ROUTE_DESCRIPTION_MAX_CHARS +
93
+ " (about two lines).",
94
+ "Shorten the description to one English sentence of at most " +
95
+ ROUTE_DESCRIPTION_MAX_CHARS +
96
+ " characters.",
97
+ ),
98
+ );
99
+ }
100
+ const previous = prePatchRoute(state, route);
101
+ if (previous === undefined) continue;
102
+ if (previous.description !== description) continue;
103
+ if (contractSignature(previous) === contractSignature(route)) continue;
104
+ warnings.push(
105
+ warning(
106
+ route,
107
+ "The route's contract changed but its description did not, so the description may no longer be accurate.",
108
+ "Update the description to match the route's permission, input schemas, dbTables and dbColumns.",
109
+ ),
110
+ );
111
+ }
112
+ return warnings;
113
+ }