@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.
- package/LICENSE +21 -0
- package/README.md +18 -2
- package/dist/context.d.ts +12 -0
- package/dist/context.js +104 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/manifest-facts.d.ts +16 -0
- package/dist/manifest-facts.js +45 -0
- package/dist/node.d.ts +19 -0
- package/dist/node.js +60 -0
- package/dist/overlay.d.ts +41 -0
- package/dist/overlay.js +91 -0
- package/dist/paths.d.ts +22 -0
- package/dist/paths.js +32 -0
- package/dist/rules/blocks.d.ts +12 -0
- package/dist/rules/blocks.js +64 -0
- package/dist/rules/config-sync.d.ts +9 -0
- package/dist/rules/config-sync.js +64 -0
- package/dist/rules/database.d.ts +21 -0
- package/dist/rules/database.js +100 -0
- package/dist/rules/i18n.d.ts +9 -0
- package/dist/rules/i18n.js +40 -0
- package/dist/rules/imports.d.ts +15 -0
- package/dist/rules/imports.js +194 -0
- package/dist/rules/job-relations.d.ts +23 -0
- package/dist/rules/job-relations.js +84 -0
- package/dist/rules/route-relations.d.ts +20 -0
- package/dist/rules/route-relations.js +113 -0
- package/dist/rules/routes-description.d.ts +14 -0
- package/dist/rules/routes-description.js +86 -0
- package/dist/rules/routes-metadata.d.ts +16 -0
- package/dist/rules/routes-metadata.js +169 -0
- package/dist/rules/routes-security.d.ts +22 -0
- package/dist/rules/routes-security.js +52 -0
- package/dist/rules/structural.d.ts +7 -0
- package/dist/rules/structural.js +33 -0
- package/dist/rules/ts-parse.d.ts +16 -0
- package/dist/rules/ts-parse.js +62 -0
- package/dist/syntax-runner.d.ts +8 -0
- package/dist/syntax-runner.js +25 -0
- package/dist/testing/fixtures.d.ts +47 -0
- package/dist/testing/fixtures.js +151 -0
- package/dist/types.d.ts +132 -0
- package/dist/types.js +12 -0
- package/dist/validator.d.ts +13 -0
- package/dist/validator.js +57 -0
- package/package.json +43 -4
- package/src/context.ts +117 -0
- package/src/index.ts +43 -0
- package/src/manifest-facts.ts +54 -0
- package/src/node.ts +75 -0
- package/src/overlay.ts +131 -0
- package/src/paths.ts +38 -0
- package/src/rules/blocks.ts +67 -0
- package/src/rules/config-sync.ts +72 -0
- package/src/rules/database.ts +133 -0
- package/src/rules/i18n.ts +42 -0
- package/src/rules/imports.ts +227 -0
- package/src/rules/job-relations.ts +126 -0
- package/src/rules/route-relations.ts +162 -0
- package/src/rules/routes-description.ts +113 -0
- package/src/rules/routes-metadata.ts +241 -0
- package/src/rules/routes-security.ts +90 -0
- package/src/rules/structural.ts +63 -0
- package/src/rules/ts-parse.ts +71 -0
- package/src/syntax-runner.ts +33 -0
- package/src/testing/fixtures.ts +210 -0
- package/src/types.ts +164 -0
- package/src/validator.ts +71 -0
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
function lineOf(source) {
|
|
14
|
+
return source?.line;
|
|
15
|
+
}
|
|
16
|
+
function error(file, message, fixHint, line) {
|
|
17
|
+
return {
|
|
18
|
+
rule: "route-table-declared",
|
|
19
|
+
severity: "error",
|
|
20
|
+
file,
|
|
21
|
+
message,
|
|
22
|
+
fix_hint: fixHint,
|
|
23
|
+
...(line === undefined ? {} : { line }),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function unusedWarning(route, name) {
|
|
27
|
+
return {
|
|
28
|
+
rule: "route-table-declared",
|
|
29
|
+
severity: "warning",
|
|
30
|
+
file: route.sourceFile,
|
|
31
|
+
message: 'dbTables declares "' + name + '" but the handler never uses it.',
|
|
32
|
+
fix_hint: 'Remove "' + name + '" from dbTables, or use it: the declaration is enforced (D-157).',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function usedNames(usage) {
|
|
36
|
+
const names = new Set(usage.used.map((use) => use.name));
|
|
37
|
+
for (const elevation of usage.elevations) {
|
|
38
|
+
for (const use of elevation.used)
|
|
39
|
+
names.add(use.name);
|
|
40
|
+
}
|
|
41
|
+
return names;
|
|
42
|
+
}
|
|
43
|
+
/** Opaque usage or raw SQL keeps the declaration authoritative instead of warning. */
|
|
44
|
+
function unusedSuppressed(usage) {
|
|
45
|
+
return (usage.opaque ||
|
|
46
|
+
usage.rawSql.length > 0 ||
|
|
47
|
+
usage.elevations.some((elevation) => elevation.opaque || elevation.tablesUnknown));
|
|
48
|
+
}
|
|
49
|
+
/** Rule 18 over the effective analysis (SPEC §9.3, D-157). */
|
|
50
|
+
export function routeTableDeclaredFindings(state) {
|
|
51
|
+
const errors = [];
|
|
52
|
+
const warnings = [];
|
|
53
|
+
for (const route of state.analysis.routes) {
|
|
54
|
+
// A hand-built analysis may predate the fact (IDE snapshots): no usage facts, no check.
|
|
55
|
+
const usage = route.relations;
|
|
56
|
+
if (route.wrapper !== "valid" || usage === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
const declared = route.dbTables === null ? null : new Set(route.dbTables);
|
|
59
|
+
if (declared !== null) {
|
|
60
|
+
for (const use of usage.used) {
|
|
61
|
+
if (declared.has(use.name))
|
|
62
|
+
continue;
|
|
63
|
+
errors.push(error(use.source.file, 'Relation "' +
|
|
64
|
+
use.name +
|
|
65
|
+
'" is used through the route db but not declared in dbTables.', 'Add "' +
|
|
66
|
+
use.name +
|
|
67
|
+
'" to dbTables (tables and views the handler touches), or stop using it.', lineOf(use.source)));
|
|
68
|
+
}
|
|
69
|
+
for (const elevation of usage.elevations) {
|
|
70
|
+
for (const use of elevation.used) {
|
|
71
|
+
if (declared.has(use.name))
|
|
72
|
+
continue;
|
|
73
|
+
errors.push(error(use.source.file, 'Relation "' +
|
|
74
|
+
use.name +
|
|
75
|
+
'" is used inside an elevation but not declared in dbTables.', 'Add "' + use.name + '" to dbTables and to the elevation tables option.', lineOf(use.source)));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const source of usage.unresolved) {
|
|
80
|
+
errors.push(error(source.file, "A relation used through the route db cannot be resolved statically.", "Use a table or view binding imported from db/ instead of a dynamic expression.", lineOf(source)));
|
|
81
|
+
}
|
|
82
|
+
for (const source of usage.rawSql) {
|
|
83
|
+
if (route.rawSql === true)
|
|
84
|
+
continue;
|
|
85
|
+
errors.push(error(source.file, "db.execute() requires rawSql: true on the route config.", route.rawSql === null
|
|
86
|
+
? "Declare rawSql: true (or false) statically, or use the query builder."
|
|
87
|
+
: "Add rawSql: true to the route config, or use the query builder.", lineOf(source)));
|
|
88
|
+
}
|
|
89
|
+
for (const elevation of usage.elevations) {
|
|
90
|
+
if (elevation.tablesUnknown || elevation.opaque)
|
|
91
|
+
continue;
|
|
92
|
+
const tables = new Set(elevation.tables);
|
|
93
|
+
for (const use of elevation.used) {
|
|
94
|
+
if (tables.has(use.name))
|
|
95
|
+
continue;
|
|
96
|
+
errors.push(error(use.source.file, 'Relation "' +
|
|
97
|
+
use.name +
|
|
98
|
+
'" is used inside the "' +
|
|
99
|
+
elevation.kind +
|
|
100
|
+
'" elevation but not declared in its tables option.', 'Add "' + use.name + '" to the elevation tables and to dbTables.', lineOf(use.source)));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (declared !== null && !unusedSuppressed(usage)) {
|
|
104
|
+
const used = usedNames(usage);
|
|
105
|
+
for (const name of route.dbTables ?? []) {
|
|
106
|
+
if (used.has(name))
|
|
107
|
+
continue;
|
|
108
|
+
warnings.push(unusedWarning(route, name));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { errors, warnings };
|
|
113
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
import type { EffectiveState } from "../overlay.js";
|
|
12
|
+
import type { ValidationError } from "../types.js";
|
|
13
|
+
/** Rule 17: every patched route should carry one current English description. */
|
|
14
|
+
export declare function routeDescriptionWarnings(state: EffectiveState): ValidationError[];
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
import { ROUTE_DESCRIPTION_MAX_CHARS } from "@facilis.dev/analyze";
|
|
12
|
+
import { normalizeProjectPath } from "../paths.js";
|
|
13
|
+
import { patchedRoutes } from "./routes-security.js";
|
|
14
|
+
const RULE = "route-description";
|
|
15
|
+
function warning(route, message, fix_hint) {
|
|
16
|
+
return {
|
|
17
|
+
rule: RULE,
|
|
18
|
+
severity: "warning",
|
|
19
|
+
file: route.sourceFile,
|
|
20
|
+
message,
|
|
21
|
+
fix_hint,
|
|
22
|
+
line: route.source.line,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** A contract list compared order-insensitively; null (unknown) stays distinct from []. */
|
|
26
|
+
function stableList(values) {
|
|
27
|
+
return values === null ? null : [...values].sort().join("\u0000");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The contract fields D-129 says the description must cover. The lists are sorted because
|
|
31
|
+
* `permission`, `dbTables` and `dbColumns` are sets: reordering them changes no behaviour.
|
|
32
|
+
*/
|
|
33
|
+
function contractSignature(route) {
|
|
34
|
+
return JSON.stringify([
|
|
35
|
+
route.wrapper,
|
|
36
|
+
stableList(route.permission),
|
|
37
|
+
stableList(route.dbTables),
|
|
38
|
+
stableList(route.dbColumns),
|
|
39
|
+
route.input.body,
|
|
40
|
+
route.input.form,
|
|
41
|
+
route.input.query,
|
|
42
|
+
route.input.params,
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
/** The pre-patch fact of the very same route, matched by file + method + path. */
|
|
46
|
+
function prePatchRoute(state, route) {
|
|
47
|
+
const file = normalizeProjectPath(route.sourceFile);
|
|
48
|
+
return state.before.routes.find((candidate) => normalizeProjectPath(candidate.sourceFile) === file &&
|
|
49
|
+
candidate.method === route.method &&
|
|
50
|
+
candidate.path === route.path);
|
|
51
|
+
}
|
|
52
|
+
/** Rule 17: every patched route should carry one current English description. */
|
|
53
|
+
export function routeDescriptionWarnings(state) {
|
|
54
|
+
const warnings = [];
|
|
55
|
+
for (const route of patchedRoutes(state)) {
|
|
56
|
+
// Rule 2 reports the wrapper error; a description for a broken route is not actionable.
|
|
57
|
+
if (route.wrapper !== "valid")
|
|
58
|
+
continue;
|
|
59
|
+
const description = route.description;
|
|
60
|
+
if (description === null || description.trim() === "") {
|
|
61
|
+
warnings.push(warning(route, "Route config must declare a description.", "Add a one-sentence description of what the route does, in English, max " +
|
|
62
|
+
ROUTE_DESCRIPTION_MAX_CHARS +
|
|
63
|
+
" characters."));
|
|
64
|
+
// Without text it cannot be over-long or stale (and the warning above already fires).
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (description.length > ROUTE_DESCRIPTION_MAX_CHARS) {
|
|
68
|
+
warnings.push(warning(route, "The route description is " +
|
|
69
|
+
description.length +
|
|
70
|
+
" characters; keep it to " +
|
|
71
|
+
ROUTE_DESCRIPTION_MAX_CHARS +
|
|
72
|
+
" (about two lines).", "Shorten the description to one English sentence of at most " +
|
|
73
|
+
ROUTE_DESCRIPTION_MAX_CHARS +
|
|
74
|
+
" characters."));
|
|
75
|
+
}
|
|
76
|
+
const previous = prePatchRoute(state, route);
|
|
77
|
+
if (previous === undefined)
|
|
78
|
+
continue;
|
|
79
|
+
if (previous.description !== description)
|
|
80
|
+
continue;
|
|
81
|
+
if (contractSignature(previous) === contractSignature(route))
|
|
82
|
+
continue;
|
|
83
|
+
warnings.push(warning(route, "The route's contract changed but its description did not, so the description may no longer be accurate.", "Update the description to match the route's permission, input schemas, dbTables and dbColumns."));
|
|
84
|
+
}
|
|
85
|
+
return warnings;
|
|
86
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
import type { EffectiveState } from "../overlay.js";
|
|
10
|
+
import type { ValidationError } from "../types.js";
|
|
11
|
+
/** Rule 4: every declared `dbTables` entry must be a table or view the project declares. */
|
|
12
|
+
export declare function routeDbTablesErrors(state: EffectiveState): ValidationError[];
|
|
13
|
+
/** Rule 5: `dbColumns` entries are `table.property` references into the effective schema. */
|
|
14
|
+
export declare function routeDbColumnsWarnings(state: EffectiveState): ValidationError[];
|
|
15
|
+
/** Rule 6: every route permission must be declared in `facilis.config.ts`; null fails closed. */
|
|
16
|
+
export declare function permissionErrors(state: EffectiveState): ValidationError[];
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
import { normalizeProjectPath } from "../paths.js";
|
|
10
|
+
import { patchedRoutes } from "./routes-security.js";
|
|
11
|
+
const COLUMN_REFERENCE = /^[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*$/;
|
|
12
|
+
function tableLookup(tables) {
|
|
13
|
+
const lookup = new Map();
|
|
14
|
+
for (const table of tables) {
|
|
15
|
+
lookup.set(table.name, table);
|
|
16
|
+
if (table.binding !== null)
|
|
17
|
+
lookup.set(table.binding, table);
|
|
18
|
+
}
|
|
19
|
+
return lookup;
|
|
20
|
+
}
|
|
21
|
+
function untouchedByPatch(route, state) {
|
|
22
|
+
const file = normalizeProjectPath(route.sourceFile);
|
|
23
|
+
return !state.patchedPaths.has(file) && !state.deletedPaths.has(file);
|
|
24
|
+
}
|
|
25
|
+
function routeLabel(route) {
|
|
26
|
+
return route.method + " " + route.path;
|
|
27
|
+
}
|
|
28
|
+
function error(rule, file, message, fix_hint, line) {
|
|
29
|
+
return {
|
|
30
|
+
rule,
|
|
31
|
+
severity: "error",
|
|
32
|
+
file,
|
|
33
|
+
message,
|
|
34
|
+
fix_hint,
|
|
35
|
+
...(line === undefined ? {} : { line }),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function warning(rule, file, message, fix_hint, line) {
|
|
39
|
+
return {
|
|
40
|
+
rule,
|
|
41
|
+
severity: "warning",
|
|
42
|
+
file,
|
|
43
|
+
message,
|
|
44
|
+
fix_hint,
|
|
45
|
+
...(line === undefined ? {} : { line }),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Rule 4: every declared `dbTables` entry must be a table or view the project declares. */
|
|
49
|
+
export function routeDbTablesErrors(state) {
|
|
50
|
+
// D-157: a view is declared by its own SQL name (the base tables stay in the view).
|
|
51
|
+
const known = new Set(tableLookup(state.schemaTables).keys());
|
|
52
|
+
for (const view of state.analysis.views ?? []) {
|
|
53
|
+
known.add(view.name);
|
|
54
|
+
if (view.binding !== null)
|
|
55
|
+
known.add(view.binding);
|
|
56
|
+
}
|
|
57
|
+
const errors = [];
|
|
58
|
+
for (const route of patchedRoutes(state)) {
|
|
59
|
+
for (const table of route.dbTables ?? []) {
|
|
60
|
+
if (known.has(table))
|
|
61
|
+
continue;
|
|
62
|
+
errors.push(error("db-tables-valid", route.sourceFile, 'dbTables references relation "' + table + '", which is not in the schema.', "Use a table or view declared in db/ (or added by this patch), or drop the reference.", route.source.line));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const route of state.before.routes) {
|
|
66
|
+
if (!untouchedByPatch(route, state))
|
|
67
|
+
continue;
|
|
68
|
+
for (const table of route.dbTables ?? []) {
|
|
69
|
+
if (!state.removedTables.includes(table))
|
|
70
|
+
continue;
|
|
71
|
+
errors.push(error("db-tables-valid", route.sourceFile, 'The patch removes table "' +
|
|
72
|
+
table +
|
|
73
|
+
'", which the existing route ' +
|
|
74
|
+
routeLabel(route) +
|
|
75
|
+
" still references.", "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."));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return errors;
|
|
79
|
+
}
|
|
80
|
+
/** Columns (Drizzle property names) dropped by the patch, keyed table.property. */
|
|
81
|
+
function removedColumns(state) {
|
|
82
|
+
const before = new Map(state.beforeSchemaTables.map((table) => [table.name, table]));
|
|
83
|
+
const removed = [];
|
|
84
|
+
for (const table of state.afterSchemaTables) {
|
|
85
|
+
const previous = before.get(table.name);
|
|
86
|
+
if (previous === undefined || previous.columns.length === 0 || table.columns.length === 0) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const current = new Set(table.columns.map((column) => column.property));
|
|
90
|
+
for (const column of previous.columns) {
|
|
91
|
+
if (!current.has(column.property))
|
|
92
|
+
removed.push({ table: table.name, column: column.property });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return removed;
|
|
96
|
+
}
|
|
97
|
+
/** Rule 5: `dbColumns` entries are `table.property` references into the effective schema. */
|
|
98
|
+
export function routeDbColumnsWarnings(state) {
|
|
99
|
+
const lookup = tableLookup(state.schemaTables);
|
|
100
|
+
const warnings = [];
|
|
101
|
+
const warn = (file, message, fix_hint, line) => {
|
|
102
|
+
warnings.push(warning("db-columns-valid", file, message, fix_hint, line));
|
|
103
|
+
};
|
|
104
|
+
for (const route of patchedRoutes(state)) {
|
|
105
|
+
for (const reference of route.dbColumns ?? []) {
|
|
106
|
+
if (!COLUMN_REFERENCE.test(reference)) {
|
|
107
|
+
warn(route.sourceFile, '"' + reference + '" is not a "table.column" reference.', "Declare dbColumns entries as table.column using Drizzle property names.", route.source.line);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const [tableName, columnName] = reference.split(".");
|
|
111
|
+
if (tableName === undefined || columnName === undefined)
|
|
112
|
+
continue;
|
|
113
|
+
const table = lookup.get(tableName);
|
|
114
|
+
if (table === undefined) {
|
|
115
|
+
warn(route.sourceFile, 'dbColumns references table "' + tableName + '", which is not in the schema.', "Use a table declared in db/schema.ts.", route.source.line);
|
|
116
|
+
}
|
|
117
|
+
else if (table.columns.length > 0 &&
|
|
118
|
+
!table.columns.some((column) => column.property === columnName)) {
|
|
119
|
+
warn(route.sourceFile, 'dbColumns references unknown column "' + columnName + '" on table "' + tableName + '".', "Use a column the schema declares, or update dbColumns with the patch.", route.source.line);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const dropped = removedColumns(state);
|
|
124
|
+
for (const route of state.before.routes) {
|
|
125
|
+
if (!untouchedByPatch(route, state))
|
|
126
|
+
continue;
|
|
127
|
+
for (const reference of route.dbColumns ?? []) {
|
|
128
|
+
const removed = dropped.some((entry) => entry.table + "." + entry.column === reference);
|
|
129
|
+
if (!removed)
|
|
130
|
+
continue;
|
|
131
|
+
warn(route.sourceFile, 'The patch drops "' +
|
|
132
|
+
reference +
|
|
133
|
+
'", which the existing route ' +
|
|
134
|
+
routeLabel(route) +
|
|
135
|
+
" still references.", "Update the route in the same patch, or keep the column.");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return warnings;
|
|
139
|
+
}
|
|
140
|
+
/** Rule 6: every route permission must be declared in `facilis.config.ts`; null fails closed. */
|
|
141
|
+
export function permissionErrors(state) {
|
|
142
|
+
const declared = new Set(state.config.permissions);
|
|
143
|
+
const errors = [];
|
|
144
|
+
for (const route of patchedRoutes(state)) {
|
|
145
|
+
if (route.permission === null) {
|
|
146
|
+
errors.push(error("permission-valid", route.sourceFile, "Route config must declare permission ([] for a public route).", 'Add permission: ["..."] or permission: [] to defineAuthorizedHandler (SPEC §7.4).', route.source.line));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
for (const value of route.permission) {
|
|
150
|
+
if (declared.has(value))
|
|
151
|
+
continue;
|
|
152
|
+
errors.push(error("permission-valid", route.sourceFile, 'Permission "' + value + '" is not declared in facilis.config.ts.', "Declare it in auth.permissions (and roles) in the same patch, or use an existing permission.", route.source.line));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const route of state.before.routes) {
|
|
156
|
+
if (!untouchedByPatch(route, state))
|
|
157
|
+
continue;
|
|
158
|
+
for (const permission of route.permission ?? []) {
|
|
159
|
+
if (!state.removedPermissions.includes(permission))
|
|
160
|
+
continue;
|
|
161
|
+
errors.push(error("permission-valid", route.sourceFile, 'The patch removes permission "' +
|
|
162
|
+
permission +
|
|
163
|
+
'", which the existing route ' +
|
|
164
|
+
routeLabel(route) +
|
|
165
|
+
" still requires.", "Update the route in the same patch, or keep the permission declared."));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return errors;
|
|
169
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
import type { EffectiveState } from "../overlay.js";
|
|
11
|
+
import type { RouteFact, ValidationError } from "../types.js";
|
|
12
|
+
/** Routes whose source file the patch creates or updates (deleted files have no facts). */
|
|
13
|
+
export declare function patchedRoutes(state: EffectiveState): RouteFact[];
|
|
14
|
+
/** Rule 2: the default export must be the resolved `defineAuthorizedHandler` call. */
|
|
15
|
+
export declare function routeSecurityErrors(state: EffectiveState): ValidationError[];
|
|
16
|
+
/** Rule 3: `dbTables` must be present; unknown (null) fails closed. */
|
|
17
|
+
export declare function routeDbTablesPresentErrors(state: EffectiveState): ValidationError[];
|
|
18
|
+
/**
|
|
19
|
+
* Rule 8: a handler that reads the request input must declare the matching schema — `body`
|
|
20
|
+
* for a JSON body, `form` for a multipart/form-data body (D-053).
|
|
21
|
+
*/
|
|
22
|
+
export declare function routeInputErrors(state: EffectiveState): ValidationError[];
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
import { normalizeProjectPath } from "../paths.js";
|
|
11
|
+
/** Routes whose source file the patch creates or updates (deleted files have no facts). */
|
|
12
|
+
export function patchedRoutes(state) {
|
|
13
|
+
return state.analysis.routes.filter((route) => state.patchedPaths.has(normalizeProjectPath(route.sourceFile)));
|
|
14
|
+
}
|
|
15
|
+
function error(rule, route, message, fix_hint) {
|
|
16
|
+
return {
|
|
17
|
+
rule,
|
|
18
|
+
severity: "error",
|
|
19
|
+
file: route.sourceFile,
|
|
20
|
+
message,
|
|
21
|
+
fix_hint,
|
|
22
|
+
line: route.source.line,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/** Rule 2: the default export must be the resolved `defineAuthorizedHandler` call. */
|
|
26
|
+
export function routeSecurityErrors(state) {
|
|
27
|
+
return patchedRoutes(state)
|
|
28
|
+
.filter((route) => route.wrapper !== "valid")
|
|
29
|
+
.map((route) => error("route-security", route, "Every API route must be written with defineAuthorizedHandler.", "Wrap the handler in defineAuthorizedHandler (security invariant 1)."));
|
|
30
|
+
}
|
|
31
|
+
/** Rule 3: `dbTables` must be present; unknown (null) fails closed. */
|
|
32
|
+
export function routeDbTablesPresentErrors(state) {
|
|
33
|
+
return patchedRoutes(state)
|
|
34
|
+
.filter((route) => route.dbTables === null)
|
|
35
|
+
.map((route) => error("db-tables-present", route, "Route config must declare dbTables (an empty array when it touches no table).", "Add dbTables: [] or the list of tables the handler uses (security invariant 2)."));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Rule 8: a handler that reads the request input must declare the matching schema — `body`
|
|
39
|
+
* for a JSON body, `form` for a multipart/form-data body (D-053).
|
|
40
|
+
*/
|
|
41
|
+
export function routeInputErrors(state) {
|
|
42
|
+
return patchedRoutes(state).flatMap((route) => {
|
|
43
|
+
const errors = [];
|
|
44
|
+
if (route.handlerBodyRead && !route.input.body) {
|
|
45
|
+
errors.push(error("route-input-validated", route, "The handler reads the request body but the route declares no body schema.", "Declare body: <ZodSchema> in the defineAuthorizedHandler config for a JSON body (security invariant 3)."));
|
|
46
|
+
}
|
|
47
|
+
if (route.handlerFormRead && !route.input.form) {
|
|
48
|
+
errors.push(error("route-input-validated", route, "The handler reads the multipart form but the route declares no form schema.", "Declare form: <ZodSchema> in the defineAuthorizedHandler config for a multipart/form-data upload (security invariant 3)."));
|
|
49
|
+
}
|
|
50
|
+
return errors;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
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
|
+
import type { FilePatch, ValidationError } from "../types.js";
|
|
7
|
+
export declare function structuralErrors(patches: readonly FilePatch[], allowedRootFiles?: readonly string[]): ValidationError[];
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
import { isAllowedWritePath, normalizeProjectPath } from "../paths.js";
|
|
7
|
+
const UNSAFE_SEGMENTS = new Set(["..", "."]);
|
|
8
|
+
function error(rule, file, message, fix_hint) {
|
|
9
|
+
return { rule, severity: "error", file, message, fix_hint };
|
|
10
|
+
}
|
|
11
|
+
export function structuralErrors(patches, allowedRootFiles = []) {
|
|
12
|
+
const errors = [];
|
|
13
|
+
for (const patch of patches) {
|
|
14
|
+
const path = normalizeProjectPath(patch.path);
|
|
15
|
+
const segments = path.split("/");
|
|
16
|
+
const unsafe = patch.path.trim().length === 0 ||
|
|
17
|
+
patch.path.startsWith("/") ||
|
|
18
|
+
segments.some((segment) => UNSAFE_SEGMENTS.has(segment));
|
|
19
|
+
if (unsafe) {
|
|
20
|
+
errors.push(error("file-path-allowed", patch.path, "Patch paths must be relative to the project root.", "Use a project-relative path with no leading slash and no '..' segment."));
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (!isAllowedWritePath(path) && !allowedRootFiles.includes(path)) {
|
|
24
|
+
errors.push(error("file-path-allowed", patch.path, '"' +
|
|
25
|
+
path +
|
|
26
|
+
'" is outside the writable roots (server/, src/, db/, lib/, components/, locales/, facilis.config.ts).', "Keep the patch under server/, src/, db/, lib/, components/ or locales/; the only allowed root file is facilis.config.ts."));
|
|
27
|
+
}
|
|
28
|
+
if (patch.action !== "delete" && (patch.content ?? "").length === 0) {
|
|
29
|
+
errors.push(error("content-present", patch.path, "Create and update patches must include file content.", "Emit the complete new file content for every create and update patch."));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return errors;
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
import type { FilePatch, SyntaxCheck, ValidationError } from "../types.js";
|
|
10
|
+
export interface ScriptSource {
|
|
11
|
+
path: string;
|
|
12
|
+
content: string;
|
|
13
|
+
}
|
|
14
|
+
/** Create/update patches that carry a script file, in patch order. */
|
|
15
|
+
export declare function scriptSources(patches: readonly FilePatch[]): ScriptSource[];
|
|
16
|
+
export declare function tsParseErrors(patches: readonly FilePatch[], syntax: SyntaxCheck): ValidationError[];
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
import { isScriptPath, normalizeProjectPath } from "../paths.js";
|
|
10
|
+
/** Create/update patches that carry a script file, in patch order. */
|
|
11
|
+
export function scriptSources(patches) {
|
|
12
|
+
const sources = [];
|
|
13
|
+
for (const patch of patches) {
|
|
14
|
+
if (patch.action === "delete" || patch.content === undefined)
|
|
15
|
+
continue;
|
|
16
|
+
const path = normalizeProjectPath(patch.path);
|
|
17
|
+
if (isScriptPath(path))
|
|
18
|
+
sources.push({ path, content: patch.content });
|
|
19
|
+
}
|
|
20
|
+
return sources;
|
|
21
|
+
}
|
|
22
|
+
function excerptOf(content, line) {
|
|
23
|
+
const text = content.split("\n")[line - 1];
|
|
24
|
+
if (text === undefined)
|
|
25
|
+
return undefined;
|
|
26
|
+
const trimmed = text.trim();
|
|
27
|
+
return trimmed.length > 160 ? trimmed.slice(0, 160) + "…" : trimmed;
|
|
28
|
+
}
|
|
29
|
+
export function tsParseErrors(patches, syntax) {
|
|
30
|
+
const sources = scriptSources(patches);
|
|
31
|
+
if (sources.length === 0)
|
|
32
|
+
return [];
|
|
33
|
+
if (syntax.status === "unavailable") {
|
|
34
|
+
return sources.map((source) => ({
|
|
35
|
+
rule: "ts-parse",
|
|
36
|
+
severity: "error",
|
|
37
|
+
file: source.path,
|
|
38
|
+
message: "TypeScript syntax could not be checked: " + syntax.reason,
|
|
39
|
+
fix_hint: "Wait for the editor's TypeScript service to load and retry; rule 1 fails closed on purpose.",
|
|
40
|
+
infrastructure: true,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
const byFile = new Map(sources.map((source) => [source.path, source]));
|
|
44
|
+
const errors = [];
|
|
45
|
+
for (const finding of syntax.findings) {
|
|
46
|
+
const source = byFile.get(finding.file);
|
|
47
|
+
if (source === undefined)
|
|
48
|
+
continue;
|
|
49
|
+
const excerpt = excerptOf(source.content, finding.line);
|
|
50
|
+
errors.push({
|
|
51
|
+
rule: "ts-parse",
|
|
52
|
+
severity: "error",
|
|
53
|
+
file: finding.file,
|
|
54
|
+
message: finding.message,
|
|
55
|
+
fix_hint: "Fix the TypeScript syntax error; a file that does not parse cannot be applied.",
|
|
56
|
+
line: finding.line,
|
|
57
|
+
column: finding.column,
|
|
58
|
+
...(excerpt === undefined ? {} : { excerpt }),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return errors;
|
|
62
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
import type { FilePatch, SyntaxChecker, SyntaxFinding } from "./types.js";
|
|
8
|
+
export declare function checkPatchSyntax(patches: readonly FilePatch[], checker: SyntaxChecker): Promise<SyntaxFinding[]>;
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
import { scriptSources } from "./rules/ts-parse.js";
|
|
8
|
+
export async function checkPatchSyntax(patches, checker) {
|
|
9
|
+
const checked = await Promise.all(scriptSources(patches).map(async (source) => ({
|
|
10
|
+
source,
|
|
11
|
+
issues: await checker.check(source.path, source.content),
|
|
12
|
+
})));
|
|
13
|
+
const findings = [];
|
|
14
|
+
for (const entry of checked) {
|
|
15
|
+
for (const issue of entry.issues) {
|
|
16
|
+
findings.push({
|
|
17
|
+
file: entry.source.path,
|
|
18
|
+
line: issue.line,
|
|
19
|
+
column: issue.column,
|
|
20
|
+
message: issue.message,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return findings;
|
|
25
|
+
}
|