@facilis.dev/ai 0.0.0-stage → 0.1.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.
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 +22 -0
  14. package/dist/paths.js +32 -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 +21 -0
  20. package/dist/rules/database.js +100 -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 +7 -0
  36. package/dist/rules/structural.js +33 -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 +47 -0
  42. package/dist/testing/fixtures.js +151 -0
  43. package/dist/types.d.ts +132 -0
  44. package/dist/types.js +12 -0
  45. package/dist/validator.d.ts +13 -0
  46. package/dist/validator.js +57 -0
  47. package/package.json +43 -4
  48. package/src/context.ts +117 -0
  49. package/src/index.ts +43 -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 +38 -0
  54. package/src/rules/blocks.ts +67 -0
  55. package/src/rules/config-sync.ts +72 -0
  56. package/src/rules/database.ts +133 -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 +63 -0
  65. package/src/rules/ts-parse.ts +71 -0
  66. package/src/syntax-runner.ts +33 -0
  67. package/src/testing/fixtures.ts +210 -0
  68. package/src/types.ts +164 -0
  69. package/src/validator.ts +71 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Rules 4 `db-tables-valid`, 5 `db-columns-valid` and 6 `permission-valid` (SPEC §9.3).
3
+ *
4
+ * The rules read the route facts the analyzer produced and check them against the effective
5
+ * facts: the schema tables (Drizzle property names for columns) and the config's permission
6
+ * list. They also catch the inverse edit: removing a table, column or permission that an
7
+ * existing route still references.
8
+ */
9
+
10
+ import type { EffectiveState } from "../overlay.ts";
11
+ import { normalizeProjectPath } from "../paths.ts";
12
+ import type { RouteFact, TableFact, ValidationError } from "../types.ts";
13
+ import { patchedRoutes } from "./routes-security.ts";
14
+
15
+ const COLUMN_REFERENCE = /^[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*$/;
16
+
17
+ function tableLookup(tables: readonly TableFact[]): Map<string, TableFact> {
18
+ const lookup = new Map<string, TableFact>();
19
+ for (const table of tables) {
20
+ lookup.set(table.name, table);
21
+ if (table.binding !== null) lookup.set(table.binding, table);
22
+ }
23
+ return lookup;
24
+ }
25
+
26
+ function untouchedByPatch(route: RouteFact, state: EffectiveState): boolean {
27
+ const file = normalizeProjectPath(route.sourceFile);
28
+ return !state.patchedPaths.has(file) && !state.deletedPaths.has(file);
29
+ }
30
+
31
+ function routeLabel(route: RouteFact): string {
32
+ return route.method + " " + route.path;
33
+ }
34
+
35
+ function error(
36
+ rule: string,
37
+ file: string,
38
+ message: string,
39
+ fix_hint: string,
40
+ line?: number,
41
+ ): ValidationError {
42
+ return {
43
+ rule,
44
+ severity: "error",
45
+ file,
46
+ message,
47
+ fix_hint,
48
+ ...(line === undefined ? {} : { line }),
49
+ };
50
+ }
51
+
52
+ function warning(
53
+ rule: string,
54
+ file: string,
55
+ message: string,
56
+ fix_hint: string,
57
+ line?: number,
58
+ ): ValidationError {
59
+ return {
60
+ rule,
61
+ severity: "warning",
62
+ file,
63
+ message,
64
+ fix_hint,
65
+ ...(line === undefined ? {} : { line }),
66
+ };
67
+ }
68
+
69
+ /** Rule 4: every declared `dbTables` entry must be a table or view the project declares. */
70
+ export function routeDbTablesErrors(state: EffectiveState): ValidationError[] {
71
+ // D-157: a view is declared by its own SQL name (the base tables stay in the view).
72
+ const known = new Set(tableLookup(state.schemaTables).keys());
73
+ for (const view of state.analysis.views ?? []) {
74
+ known.add(view.name);
75
+ if (view.binding !== null) known.add(view.binding);
76
+ }
77
+ const errors: ValidationError[] = [];
78
+ for (const route of patchedRoutes(state)) {
79
+ for (const table of route.dbTables ?? []) {
80
+ if (known.has(table)) continue;
81
+ errors.push(
82
+ error(
83
+ "db-tables-valid",
84
+ route.sourceFile,
85
+ 'dbTables references relation "' + table + '", which is not in the schema.',
86
+ "Use a table or view declared in db/ (or added by this patch), or drop the reference.",
87
+ route.source.line,
88
+ ),
89
+ );
90
+ }
91
+ }
92
+ for (const route of state.before.routes) {
93
+ if (!untouchedByPatch(route, state)) continue;
94
+ for (const table of route.dbTables ?? []) {
95
+ if (!state.removedTables.includes(table)) continue;
96
+ errors.push(
97
+ error(
98
+ "db-tables-valid",
99
+ route.sourceFile,
100
+ 'The patch removes table "' +
101
+ table +
102
+ '", which the existing route ' +
103
+ routeLabel(route) +
104
+ " still references.",
105
+ "Update the route (or delete it) in the same patch, or keep the table. If the change was meant to be additive, re-emit db/schema.ts with every existing table plus the new one.",
106
+ ),
107
+ );
108
+ }
109
+ }
110
+ return errors;
111
+ }
112
+
113
+ /** Columns (Drizzle property names) dropped by the patch, keyed table.property. */
114
+ function removedColumns(state: EffectiveState): { table: string; column: string }[] {
115
+ const before = new Map(state.beforeSchemaTables.map((table) => [table.name, table]));
116
+ const removed: { table: string; column: string }[] = [];
117
+ for (const table of state.afterSchemaTables) {
118
+ const previous = before.get(table.name);
119
+ if (previous === undefined || previous.columns.length === 0 || table.columns.length === 0) {
120
+ continue;
121
+ }
122
+ const current = new Set(table.columns.map((column) => column.property));
123
+ for (const column of previous.columns) {
124
+ if (!current.has(column.property))
125
+ removed.push({ table: table.name, column: column.property });
126
+ }
127
+ }
128
+ return removed;
129
+ }
130
+
131
+ /** Rule 5: `dbColumns` entries are `table.property` references into the effective schema. */
132
+ export function routeDbColumnsWarnings(state: EffectiveState): ValidationError[] {
133
+ const lookup = tableLookup(state.schemaTables);
134
+ const warnings: ValidationError[] = [];
135
+ const warn = (file: string, message: string, fix_hint: string, line?: number): void => {
136
+ warnings.push(warning("db-columns-valid", file, message, fix_hint, line));
137
+ };
138
+ for (const route of patchedRoutes(state)) {
139
+ for (const reference of route.dbColumns ?? []) {
140
+ if (!COLUMN_REFERENCE.test(reference)) {
141
+ warn(
142
+ route.sourceFile,
143
+ '"' + reference + '" is not a "table.column" reference.',
144
+ "Declare dbColumns entries as table.column using Drizzle property names.",
145
+ route.source.line,
146
+ );
147
+ continue;
148
+ }
149
+ const [tableName, columnName] = reference.split(".");
150
+ if (tableName === undefined || columnName === undefined) continue;
151
+ const table = lookup.get(tableName);
152
+ if (table === undefined) {
153
+ warn(
154
+ route.sourceFile,
155
+ 'dbColumns references table "' + tableName + '", which is not in the schema.',
156
+ "Use a table declared in db/schema.ts.",
157
+ route.source.line,
158
+ );
159
+ } else if (
160
+ table.columns.length > 0 &&
161
+ !table.columns.some((column) => column.property === columnName)
162
+ ) {
163
+ warn(
164
+ route.sourceFile,
165
+ 'dbColumns references unknown column "' + columnName + '" on table "' + tableName + '".',
166
+ "Use a column the schema declares, or update dbColumns with the patch.",
167
+ route.source.line,
168
+ );
169
+ }
170
+ }
171
+ }
172
+ const dropped = removedColumns(state);
173
+ for (const route of state.before.routes) {
174
+ if (!untouchedByPatch(route, state)) continue;
175
+ for (const reference of route.dbColumns ?? []) {
176
+ const removed = dropped.some((entry) => entry.table + "." + entry.column === reference);
177
+ if (!removed) continue;
178
+ warn(
179
+ route.sourceFile,
180
+ 'The patch drops "' +
181
+ reference +
182
+ '", which the existing route ' +
183
+ routeLabel(route) +
184
+ " still references.",
185
+ "Update the route in the same patch, or keep the column.",
186
+ );
187
+ }
188
+ }
189
+ return warnings;
190
+ }
191
+
192
+ /** Rule 6: every route permission must be declared in `facilis.config.ts`; null fails closed. */
193
+ export function permissionErrors(state: EffectiveState): ValidationError[] {
194
+ const declared = new Set(state.config.permissions);
195
+ const errors: ValidationError[] = [];
196
+ for (const route of patchedRoutes(state)) {
197
+ if (route.permission === null) {
198
+ errors.push(
199
+ error(
200
+ "permission-valid",
201
+ route.sourceFile,
202
+ "Route config must declare permission ([] for a public route).",
203
+ 'Add permission: ["..."] or permission: [] to defineAuthorizedHandler (SPEC §7.4).',
204
+ route.source.line,
205
+ ),
206
+ );
207
+ continue;
208
+ }
209
+ for (const value of route.permission) {
210
+ if (declared.has(value)) continue;
211
+ errors.push(
212
+ error(
213
+ "permission-valid",
214
+ route.sourceFile,
215
+ 'Permission "' + value + '" is not declared in facilis.config.ts.',
216
+ "Declare it in auth.permissions (and roles) in the same patch, or use an existing permission.",
217
+ route.source.line,
218
+ ),
219
+ );
220
+ }
221
+ }
222
+ for (const route of state.before.routes) {
223
+ if (!untouchedByPatch(route, state)) continue;
224
+ for (const permission of route.permission ?? []) {
225
+ if (!state.removedPermissions.includes(permission)) continue;
226
+ errors.push(
227
+ error(
228
+ "permission-valid",
229
+ route.sourceFile,
230
+ 'The patch removes permission "' +
231
+ permission +
232
+ '", which the existing route ' +
233
+ routeLabel(route) +
234
+ " still requires.",
235
+ "Update the route in the same patch, or keep the permission declared.",
236
+ ),
237
+ );
238
+ }
239
+ }
240
+ return errors;
241
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Rules 2 `route-security`, 3 `db-tables-present` and 8 `route-input-validated`
3
+ * (SPEC §9.3), checked on the routes whose source file the patch creates or updates.
4
+ *
5
+ * The facts come from @facilis.dev/analyze's route pass: wrapper resolution, `dbTables` and the
6
+ * handler's body read are AST facts, so a route cannot look valid by mentioning the wrapper
7
+ * in a comment or a string. A route file with an invalid wrapper is still listed by the
8
+ * analyzer, which is what lets rule 2 see it.
9
+ */
10
+
11
+ import type { EffectiveState } from "../overlay.ts";
12
+ import { normalizeProjectPath } from "../paths.ts";
13
+ import type { RouteFact, ValidationError } from "../types.ts";
14
+
15
+ /** Routes whose source file the patch creates or updates (deleted files have no facts). */
16
+ export function patchedRoutes(state: EffectiveState): RouteFact[] {
17
+ return state.analysis.routes.filter((route) =>
18
+ state.patchedPaths.has(normalizeProjectPath(route.sourceFile)),
19
+ );
20
+ }
21
+
22
+ function error(rule: string, route: RouteFact, message: string, fix_hint: string): ValidationError {
23
+ return {
24
+ rule,
25
+ severity: "error",
26
+ file: route.sourceFile,
27
+ message,
28
+ fix_hint,
29
+ line: route.source.line,
30
+ };
31
+ }
32
+
33
+ /** Rule 2: the default export must be the resolved `defineAuthorizedHandler` call. */
34
+ export function routeSecurityErrors(state: EffectiveState): ValidationError[] {
35
+ return patchedRoutes(state)
36
+ .filter((route) => route.wrapper !== "valid")
37
+ .map((route) =>
38
+ error(
39
+ "route-security",
40
+ route,
41
+ "Every API route must be written with defineAuthorizedHandler.",
42
+ "Wrap the handler in defineAuthorizedHandler (security invariant 1).",
43
+ ),
44
+ );
45
+ }
46
+
47
+ /** Rule 3: `dbTables` must be present; unknown (null) fails closed. */
48
+ export function routeDbTablesPresentErrors(state: EffectiveState): ValidationError[] {
49
+ return patchedRoutes(state)
50
+ .filter((route) => route.dbTables === null)
51
+ .map((route) =>
52
+ error(
53
+ "db-tables-present",
54
+ route,
55
+ "Route config must declare dbTables (an empty array when it touches no table).",
56
+ "Add dbTables: [] or the list of tables the handler uses (security invariant 2).",
57
+ ),
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Rule 8: a handler that reads the request input must declare the matching schema — `body`
63
+ * for a JSON body, `form` for a multipart/form-data body (D-053).
64
+ */
65
+ export function routeInputErrors(state: EffectiveState): ValidationError[] {
66
+ return patchedRoutes(state).flatMap((route) => {
67
+ const errors: ValidationError[] = [];
68
+ if (route.handlerBodyRead && !route.input.body) {
69
+ errors.push(
70
+ error(
71
+ "route-input-validated",
72
+ route,
73
+ "The handler reads the request body but the route declares no body schema.",
74
+ "Declare body: <ZodSchema> in the defineAuthorizedHandler config for a JSON body (security invariant 3).",
75
+ ),
76
+ );
77
+ }
78
+ if (route.handlerFormRead && !route.input.form) {
79
+ errors.push(
80
+ error(
81
+ "route-input-validated",
82
+ route,
83
+ "The handler reads the multipart form but the route declares no form schema.",
84
+ "Declare form: <ZodSchema> in the defineAuthorizedHandler config for a multipart/form-data upload (security invariant 3).",
85
+ ),
86
+ );
87
+ }
88
+ return errors;
89
+ });
90
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Rule 11 `file-path-allowed` plus the two structural checks the parser owns
3
+ * (relative path, content present). These need no project context, so they also run
4
+ * when the parser validates a raw answer before any context is assembled.
5
+ */
6
+
7
+ import { isAllowedWritePath, normalizeProjectPath } from "../paths.ts";
8
+ import type { FilePatch, ValidationError } from "../types.ts";
9
+
10
+ const UNSAFE_SEGMENTS = new Set(["..", "."]);
11
+
12
+ function error(rule: string, file: string, message: string, fix_hint: string): ValidationError {
13
+ return { rule, severity: "error", file, message, fix_hint };
14
+ }
15
+
16
+ export function structuralErrors(
17
+ patches: readonly FilePatch[],
18
+ allowedRootFiles: readonly string[] = [],
19
+ ): ValidationError[] {
20
+ const errors: ValidationError[] = [];
21
+ for (const patch of patches) {
22
+ const path = normalizeProjectPath(patch.path);
23
+ const segments = path.split("/");
24
+ const unsafe =
25
+ patch.path.trim().length === 0 ||
26
+ patch.path.startsWith("/") ||
27
+ segments.some((segment) => UNSAFE_SEGMENTS.has(segment));
28
+ if (unsafe) {
29
+ errors.push(
30
+ error(
31
+ "file-path-allowed",
32
+ patch.path,
33
+ "Patch paths must be relative to the project root.",
34
+ "Use a project-relative path with no leading slash and no '..' segment.",
35
+ ),
36
+ );
37
+ continue;
38
+ }
39
+ if (!isAllowedWritePath(path) && !allowedRootFiles.includes(path)) {
40
+ errors.push(
41
+ error(
42
+ "file-path-allowed",
43
+ patch.path,
44
+ '"' +
45
+ path +
46
+ '" is outside the writable roots (server/, src/, db/, lib/, components/, locales/, facilis.config.ts).',
47
+ "Keep the patch under server/, src/, db/, lib/, components/ or locales/; the only allowed root file is facilis.config.ts.",
48
+ ),
49
+ );
50
+ }
51
+ if (patch.action !== "delete" && (patch.content ?? "").length === 0) {
52
+ errors.push(
53
+ error(
54
+ "content-present",
55
+ patch.path,
56
+ "Create and update patches must include file content.",
57
+ "Emit the complete new file content for every create and update patch.",
58
+ ),
59
+ );
60
+ }
61
+ }
62
+ return errors;
63
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Rule 1 `ts-parse` (SPEC §9.3): every script file the patch writes must parse.
3
+ *
4
+ * The parser itself is injected — the IDE passes the analyzer worker's oxc parser, Node
5
+ * tooling passes `@facilis.dev/ai/node` — so `@facilis.dev/ai` never imports TypeScript. When the
6
+ * injected checker is unavailable the rule fails closed: a patch is never written with
7
+ * rule 1 silently skipped.
8
+ */
9
+
10
+ import { isScriptPath, normalizeProjectPath } from "../paths.ts";
11
+ import type { FilePatch, SyntaxCheck, ValidationError } from "../types.ts";
12
+
13
+ export interface ScriptSource {
14
+ path: string;
15
+ content: string;
16
+ }
17
+
18
+ /** Create/update patches that carry a script file, in patch order. */
19
+ export function scriptSources(patches: readonly FilePatch[]): ScriptSource[] {
20
+ const sources: ScriptSource[] = [];
21
+ for (const patch of patches) {
22
+ if (patch.action === "delete" || patch.content === undefined) continue;
23
+ const path = normalizeProjectPath(patch.path);
24
+ if (isScriptPath(path)) sources.push({ path, content: patch.content });
25
+ }
26
+ return sources;
27
+ }
28
+
29
+ function excerptOf(content: string, line: number): string | undefined {
30
+ const text = content.split("\n")[line - 1];
31
+ if (text === undefined) return undefined;
32
+ const trimmed = text.trim();
33
+ return trimmed.length > 160 ? trimmed.slice(0, 160) + "…" : trimmed;
34
+ }
35
+
36
+ export function tsParseErrors(
37
+ patches: readonly FilePatch[],
38
+ syntax: SyntaxCheck,
39
+ ): ValidationError[] {
40
+ const sources = scriptSources(patches);
41
+ if (sources.length === 0) return [];
42
+ if (syntax.status === "unavailable") {
43
+ return sources.map((source) => ({
44
+ rule: "ts-parse",
45
+ severity: "error",
46
+ file: source.path,
47
+ message: "TypeScript syntax could not be checked: " + syntax.reason,
48
+ fix_hint:
49
+ "Wait for the editor's TypeScript service to load and retry; rule 1 fails closed on purpose.",
50
+ infrastructure: true,
51
+ }));
52
+ }
53
+ const byFile = new Map(sources.map((source) => [source.path, source]));
54
+ const errors: ValidationError[] = [];
55
+ for (const finding of syntax.findings) {
56
+ const source = byFile.get(finding.file);
57
+ if (source === undefined) continue;
58
+ const excerpt = excerptOf(source.content, finding.line);
59
+ errors.push({
60
+ rule: "ts-parse",
61
+ severity: "error",
62
+ file: finding.file,
63
+ message: finding.message,
64
+ fix_hint: "Fix the TypeScript syntax error; a file that does not parse cannot be applied.",
65
+ line: finding.line,
66
+ column: finding.column,
67
+ ...(excerpt === undefined ? {} : { excerpt }),
68
+ });
69
+ }
70
+ return errors;
71
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Runs the injected TypeScript parser over the patch's script files (rule 1).
3
+ *
4
+ * Kept separate from the rule so the async parser call happens once, before the pure
5
+ * validator runs: `checkPatchSyntax()` is the only async step of validation.
6
+ */
7
+
8
+ import { scriptSources } from "./rules/ts-parse.ts";
9
+ import type { FilePatch, SyntaxChecker, SyntaxFinding } from "./types.ts";
10
+
11
+ export async function checkPatchSyntax(
12
+ patches: readonly FilePatch[],
13
+ checker: SyntaxChecker,
14
+ ): Promise<SyntaxFinding[]> {
15
+ const checked = await Promise.all(
16
+ scriptSources(patches).map(async (source) => ({
17
+ source,
18
+ issues: await checker.check(source.path, source.content),
19
+ })),
20
+ );
21
+ const findings: SyntaxFinding[] = [];
22
+ for (const entry of checked) {
23
+ for (const issue of entry.issues) {
24
+ findings.push({
25
+ file: entry.source.path,
26
+ line: issue.line,
27
+ column: issue.column,
28
+ message: issue.message,
29
+ });
30
+ }
31
+ }
32
+ return findings;
33
+ }