@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,64 @@
1
+ /**
2
+ * Rule 7 `block-import-valid` (SPEC §9.3): every block the patch imports must match a
3
+ * registered `BlockManifest`. The block supply chain is retired (D-131, no `@facilis.dev/blocks`
4
+ * package ships), so in practice the manifest registers none and the guard rejects every block
5
+ * import; it stays in the rule set as the contract's reserved slot (§7.2/§7.3).
6
+ *
7
+ * The imports are the analyzer's FileFacts, so re-exports and dynamic imports are covered
8
+ * by the same AST truth the rest of validation uses.
9
+ */
10
+ const DEFAULT_BLOCKS_PACKAGE = "@facilis.dev/blocks";
11
+ /** The import names a block import introduces: named imports and default-import locals. */
12
+ function namesOf(imported) {
13
+ if (imported.kind === "named" && imported.imported !== null)
14
+ return [imported.imported];
15
+ if (imported.kind === "default" && imported.local !== null)
16
+ return [imported.local];
17
+ return [];
18
+ }
19
+ /** The block name an import points at, or null when the specifier is not a block import. */
20
+ function blockNameFromPath(specifier, packages) {
21
+ for (const pkg of packages) {
22
+ if (specifier === pkg)
23
+ return null;
24
+ if (specifier.startsWith(pkg + "/")) {
25
+ const name = specifier.slice(pkg.length + 1).split("/")[0];
26
+ return name === undefined || name.length === 0 ? null : name;
27
+ }
28
+ }
29
+ const segments = specifier.split("/");
30
+ const index = segments.indexOf("blocks");
31
+ if (index === -1)
32
+ return null;
33
+ const name = segments[index + 1];
34
+ return name === undefined || name.length === 0 ? null : name;
35
+ }
36
+ export function blockImportErrors(state, context) {
37
+ // Blocks are no longer configurable: the framework ships one reviewed package (D-067).
38
+ const packages = new Set([DEFAULT_BLOCKS_PACKAGE]);
39
+ const known = new Set(context.blocks.map((block) => block.name));
40
+ const errors = [];
41
+ const seen = new Set();
42
+ for (const facts of state.patchedFileFacts) {
43
+ for (const imported of facts.imports) {
44
+ const fromPackageRoot = packages.has(imported.specifier);
45
+ const pathName = blockNameFromPath(imported.specifier, packages);
46
+ const candidates = fromPackageRoot ? namesOf(imported) : pathName === null ? [] : [pathName];
47
+ for (const candidate of candidates) {
48
+ const key = facts.path + "\u0000" + candidate;
49
+ if (known.has(candidate) || seen.has(key))
50
+ continue;
51
+ seen.add(key);
52
+ errors.push({
53
+ rule: "block-import-valid",
54
+ severity: "error",
55
+ file: facts.path,
56
+ message: 'Block import "' + candidate + '" does not match a registered BlockManifest.',
57
+ fix_hint: "Import an existing block manifest, or stop importing it; do not create new blocks unprompted (SPEC §9.2 rule 5).",
58
+ line: imported.source.line,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ return errors;
64
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Rule 16 `role-config-sync` (SPEC §9.3): changing roles or permissions in
3
+ * `facilis.config.ts` requires a new migration that updates the `role_permissions`
4
+ * seed. The table is a projection of the config (SPEC §5.3), so without the migration the
5
+ * live database silently disagrees with the code.
6
+ */
7
+ import { type EffectiveState } from "../overlay.js";
8
+ import type { FilePatch, ValidationError } from "../types.js";
9
+ export declare function roleConfigSyncErrors(patches: readonly FilePatch[], state: EffectiveState): ValidationError[];
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Rule 16 `role-config-sync` (SPEC §9.3): changing roles or permissions in
3
+ * `facilis.config.ts` requires a new migration that updates the `role_permissions`
4
+ * seed. The table is a projection of the config (SPEC §5.3), so without the migration the
5
+ * live database silently disagrees with the code.
6
+ */
7
+ import { hasMigrationPatch, CONFIG_PATH } from "../overlay.js";
8
+ import { normalizeProjectPath } from "../paths.js";
9
+ function changedFacts(before, after) {
10
+ const changes = [];
11
+ for (const permission of after.permissions) {
12
+ if (!before.permissions.includes(permission))
13
+ changes.push('adds permission "' + permission + '"');
14
+ }
15
+ for (const permission of before.permissions) {
16
+ if (!after.permissions.includes(permission))
17
+ changes.push('removes permission "' + permission + '"');
18
+ }
19
+ const beforeRoles = JSON.stringify(before.roles);
20
+ const afterRoles = JSON.stringify(after.roles);
21
+ if (beforeRoles !== afterRoles)
22
+ changes.push("changes auth.roles");
23
+ if ((before.defaultRole ?? "") !== (after.defaultRole ?? "")) {
24
+ changes.push('changes defaultRole to "' + (after.defaultRole ?? "") + '"');
25
+ }
26
+ return changes;
27
+ }
28
+ export function roleConfigSyncErrors(patches, state) {
29
+ const touchesConfig = patches.some((patch) => {
30
+ return normalizeProjectPath(patch.path) === CONFIG_PATH && patch.action !== "delete";
31
+ });
32
+ if (!touchesConfig)
33
+ return [];
34
+ const errors = [];
35
+ const after = state.config;
36
+ // A config patch the scan cannot read is unverifiable: rule 16 fails closed instead of
37
+ // assuming "no auth change". The pre-patch source may be unparseable for the same
38
+ // reason, so the check is on the patched content itself.
39
+ if (!after.parsed) {
40
+ errors.push({
41
+ rule: "role-config-sync",
42
+ severity: "error",
43
+ file: CONFIG_PATH,
44
+ message: "The auth block of the patched facilis.config.ts could not be read, so the role/permission sync cannot be verified.",
45
+ fix_hint: "Keep the auth declaration as an object literal inside defineConfig so the validator can read it.",
46
+ });
47
+ return errors;
48
+ }
49
+ const changes = changedFacts(state.before.config, after);
50
+ if (changes.length === 0 || hasMigrationPatch(patches, state.migrationsDir))
51
+ return [];
52
+ errors.push({
53
+ rule: "role-config-sync",
54
+ severity: "error",
55
+ file: CONFIG_PATH,
56
+ message: "The patch " +
57
+ changes.slice(0, 3).join(", ") +
58
+ " but ships no migration updating role_permissions.",
59
+ fix_hint: "Add a migration under " +
60
+ state.migrationsDir +
61
+ "/ that reseeds role_permissions from the config (SPEC §5.3, rule 16).",
62
+ });
63
+ return errors;
64
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Rules 10 `rls-on-scoped-table`, 14 `migration-generated` and 15
3
+ * `destructive-confirmed` (SPEC §9.3), read from the effective code schema.
4
+ *
5
+ * Only tables the project declares itself count (`source.file === db/schema.ts`): the
6
+ * framework tables re-exported from `@facilis.dev/auth` ship their own RLS and must not be
7
+ * re-flagged here. Scope is the analyzer's ScopeFact or a tenant/owner column property.
8
+ */
9
+ import type { EffectiveState } from "../overlay.js";
10
+ import type { FilePatch, ValidationError } from "../types.js";
11
+ /**
12
+ * Rule 10: a schema rewrite is a complete re-emission of the schema, so every scoped table
13
+ * in the result must ship its RLS policy — .enableRLS() plus a policy (invariant 4). The
14
+ * check is not limited to new tables: dropping the policy from an existing scoped table is
15
+ * the same security regression.
16
+ */
17
+ export declare function rlsErrors(state: EffectiveState): ValidationError[];
18
+ /** Rule 14: a schema edit must ship a generated migration file (warning). */
19
+ export declare function migrationWarnings(patches: readonly FilePatch[], state: EffectiveState): ValidationError[];
20
+ /** The schema removals in a patch set (rule 15's predicate), for callers that need the
21
+ * fact without the finding (the IDE flags a write destructive, never blocks it). */
22
+ export declare function destructiveChanges(state: EffectiveState): string[];
23
+ /** Rule 15: dropping a table or column is destructive and must be confirmed. */
24
+ export declare function destructiveErrors(state: EffectiveState, requiresConfirmation: boolean): ValidationError[];
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Rules 10 `rls-on-scoped-table`, 14 `migration-generated` and 15
3
+ * `destructive-confirmed` (SPEC §9.3), read from the effective code schema.
4
+ *
5
+ * Only tables the project declares itself count (`source.file === db/schema.ts`): the
6
+ * framework tables re-exported from `@facilis.dev/auth` ship their own RLS and must not be
7
+ * re-flagged here. Scope is the analyzer's ScopeFact or a tenant/owner column property.
8
+ */
9
+ import { hasMigrationPatch, SCHEMA_PATH } from "../overlay.js";
10
+ import { normalizeProjectPath } from "../paths.js";
11
+ function error(rule, file, message, fix_hint) {
12
+ return { rule, severity: "error", file, message, fix_hint };
13
+ }
14
+ function warning(rule, file, message, fix_hint) {
15
+ return { rule, severity: "warning", file, message, fix_hint };
16
+ }
17
+ /** True when this patch set touches the project schema file (create, update or delete). */
18
+ function touchesSchema(state) {
19
+ return state.patchedPaths.has(SCHEMA_PATH) || state.deletedPaths.has(SCHEMA_PATH);
20
+ }
21
+ function normalized(value) {
22
+ return value.replaceAll("_", "").replaceAll("-", "").toLowerCase();
23
+ }
24
+ /** A table is scoped when the analyzer saw a scope or a configured tenant/owner column. */
25
+ function isScoped(table, tenantColumns, ownerColumns) {
26
+ if (table.scope !== null)
27
+ return true;
28
+ const names = new Set([...tenantColumns, ...ownerColumns].map(normalized));
29
+ return table.columns.some((column) => names.has(normalized(column.property)) || names.has(normalized(column.name)));
30
+ }
31
+ /**
32
+ * Rule 10: a schema rewrite is a complete re-emission of the schema, so every scoped table
33
+ * in the result must ship its RLS policy — .enableRLS() plus a policy (invariant 4). The
34
+ * check is not limited to new tables: dropping the policy from an existing scoped table is
35
+ * the same security regression.
36
+ */
37
+ export function rlsErrors(state) {
38
+ if (!touchesSchema(state))
39
+ return [];
40
+ const errors = [];
41
+ for (const table of state.afterSchemaTables) {
42
+ if (!isScoped(table, state.config.tenantColumns, state.config.ownerColumns))
43
+ continue;
44
+ if (table.rlsEnabled && table.policies.length > 0)
45
+ continue;
46
+ errors.push(error("rls-on-scoped-table", SCHEMA_PATH, 'Scoped table "' + table.name + '" has no RLS policy in the patched db/schema.ts.', "Add .enableRLS() and a tenantIsolationPolicy()/ownerIsolationPolicy() (or pgPolicy) to the table in db/schema.ts (invariant 4)."));
47
+ }
48
+ return errors;
49
+ }
50
+ /** Rule 14: a schema edit must ship a generated migration file (warning). */
51
+ export function migrationWarnings(patches, state) {
52
+ const touches = patches.some((patch) => {
53
+ const path = normalizeProjectPath(patch.path);
54
+ return path === SCHEMA_PATH && patch.action !== "delete";
55
+ });
56
+ if (!touches)
57
+ return [];
58
+ if (hasMigrationPatch(patches, state.migrationsDir))
59
+ return [];
60
+ return [
61
+ warning("migration-generated", SCHEMA_PATH, "The patch changes the schema without shipping a migration.", "Add the drizzle-kit generated migration (.sql) under " +
62
+ state.migrationsDir +
63
+ "/ in the same patch."),
64
+ ];
65
+ }
66
+ function removedColumns(before, after) {
67
+ const afterByName = new Map(after.map((table) => [table.name, table]));
68
+ const removed = [];
69
+ for (const table of before) {
70
+ const current = afterByName.get(table.name);
71
+ if (current === undefined || table.columns.length === 0 || current.columns.length === 0) {
72
+ continue;
73
+ }
74
+ const currentColumns = new Set(current.columns.map((column) => column.property));
75
+ for (const column of table.columns) {
76
+ if (!currentColumns.has(column.property))
77
+ removed.push(table.name + "." + column.property);
78
+ }
79
+ }
80
+ return removed;
81
+ }
82
+ /** The schema removals in a patch set (rule 15's predicate), for callers that need the
83
+ * fact without the finding (the IDE flags a write destructive, never blocks it). */
84
+ export function destructiveChanges(state) {
85
+ const afterNames = new Set(state.afterSchemaTables.map((table) => table.name));
86
+ const droppedTables = state.beforeSchemaTables
87
+ .map((table) => table.name)
88
+ .filter((name) => !afterNames.has(name));
89
+ const droppedColumns = removedColumns(state.beforeSchemaTables, state.afterSchemaTables);
90
+ return [
91
+ ...droppedTables.map((name) => 'drops table "' + name + '"'),
92
+ ...droppedColumns.map((name) => 'drops column "' + name + '"'),
93
+ ];
94
+ }
95
+ /** Rule 15: dropping a table or column is destructive and must be confirmed. */
96
+ export function destructiveErrors(state, requiresConfirmation) {
97
+ const changes = destructiveChanges(state);
98
+ if (changes.length === 0 || requiresConfirmation)
99
+ return [];
100
+ const shown = changes.slice(0, 3).join(", ");
101
+ const suffix = changes.length > 3 ? " (and " + (changes.length - 3) + " more)" : "";
102
+ return [
103
+ error("destructive-confirmed", SCHEMA_PATH, "Destructive schema change without confirmation: the patch " + shown + suffix + ".", "If the removal was not intended, re-emit the complete db/schema.ts keeping every existing table and column and add only the requested change. For a deliberate removal, set requires_confirmation to true and explain it in notes (SPEC §9.2 rule 9 / §9.3 rule 15)."),
104
+ ];
105
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Rule 13 `i18n-keys-registered` (warning, SPEC §9.3): every static `t("key")` call in
3
+ * a patched file must have its key in the effective default-locale catalogue. Keys are
4
+ * literal facts from the analyzer: an interpolated key cannot be checked and guessing would
5
+ * produce false warnings.
6
+ */
7
+ import { type EffectiveState } from "../overlay.js";
8
+ import type { ValidationError } from "../types.js";
9
+ export declare function i18nWarnings(state: EffectiveState): ValidationError[];
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Rule 13 `i18n-keys-registered` (warning, SPEC §9.3): every static `t("key")` call in
3
+ * a patched file must have its key in the effective default-locale catalogue. Keys are
4
+ * literal facts from the analyzer: an interpolated key cannot be checked and guessing would
5
+ * produce false warnings.
6
+ */
7
+ import { LOCALES_PATH } from "../overlay.js";
8
+ import { isScriptPath } from "../paths.js";
9
+ export function i18nWarnings(state) {
10
+ const warnings = [];
11
+ if (!state.localesValid) {
12
+ warnings.push({
13
+ rule: "i18n-keys-registered",
14
+ severity: "warning",
15
+ file: LOCALES_PATH,
16
+ message: "The patched locales/en.json is not a valid JSON catalogue, so i18n keys cannot be verified.",
17
+ fix_hint: "Emit locales/en.json as a complete JSON object of key/value strings.",
18
+ });
19
+ }
20
+ const known = new Set(Object.keys(state.locales));
21
+ for (const facts of state.patchedFileFacts) {
22
+ if (!isScriptPath(facts.path))
23
+ continue;
24
+ const reported = new Set();
25
+ for (const call of facts.translationCalls) {
26
+ if (known.has(call.key) || reported.has(call.key))
27
+ continue;
28
+ reported.add(call.key);
29
+ warnings.push({
30
+ rule: "i18n-keys-registered",
31
+ severity: "warning",
32
+ file: facts.path,
33
+ message: 't("' + call.key + '") is not registered in locales/en.json.',
34
+ fix_hint: "Add the key to locales/en.json in the same patch, or reuse a registered key.",
35
+ line: call.source.line,
36
+ });
37
+ }
38
+ }
39
+ return warnings;
40
+ }
@@ -0,0 +1,15 @@
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
+ import type { EffectiveState } from "../overlay.js";
13
+ import type { PatchContext, ValidationError } from "../types.js";
14
+ export declare function rawDbAccessErrors(state: EffectiveState): ValidationError[];
15
+ export declare function importResolveErrors(state: EffectiveState, context: PatchContext): ValidationError[];
@@ -0,0 +1,194 @@
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
+ import { normalizeProjectPath } from "../paths.js";
13
+ const RAW_DRIVER = /^drizzle-orm\/(?:pglite|node-postgres|postgres-js|bun-sqlite|neon|vercel-postgres|aws-data-api|libsql|expo-sqlite|d1|op-sqlite|singlestore|tidb-serverless)$/;
14
+ const RAW_CLIENTS = new Set(["@electric-sql/pglite", "postgres", "pg"]);
15
+ const NODE_BUILTINS = new Set([
16
+ "assert",
17
+ "async_hooks",
18
+ "buffer",
19
+ "child_process",
20
+ "cluster",
21
+ "console",
22
+ "constants",
23
+ "crypto",
24
+ "dgram",
25
+ "diagnostics_channel",
26
+ "dns",
27
+ "domain",
28
+ "events",
29
+ "fs",
30
+ "http",
31
+ "http2",
32
+ "https",
33
+ "inspector",
34
+ "module",
35
+ "net",
36
+ "os",
37
+ "path",
38
+ "perf_hooks",
39
+ "process",
40
+ "punycode",
41
+ "querystring",
42
+ "readline",
43
+ "repl",
44
+ "stream",
45
+ "string_decoder",
46
+ "sys",
47
+ "timers",
48
+ "tls",
49
+ "trace_events",
50
+ "tty",
51
+ "url",
52
+ "util",
53
+ "v8",
54
+ "vm",
55
+ "wasi",
56
+ "worker_threads",
57
+ "zlib",
58
+ ]);
59
+ const RESOLVABLE_EXTENSIONS = [
60
+ ".ts",
61
+ ".tsx",
62
+ ".js",
63
+ ".jsx",
64
+ ".mjs",
65
+ ".cjs",
66
+ ".json",
67
+ ".css",
68
+ ".svg",
69
+ ];
70
+ const HAS_EXTENSION = /\.[A-Za-z0-9]+$/;
71
+ function error(rule, file, message, fix_hint, line) {
72
+ return {
73
+ rule,
74
+ severity: "error",
75
+ file,
76
+ message,
77
+ fix_hint,
78
+ ...(line === undefined ? {} : { line }),
79
+ };
80
+ }
81
+ /** Imports deduped per statement: a named-import clause produces one fact per name. */
82
+ function statementImports(imports) {
83
+ const seen = new Set();
84
+ const unique = [];
85
+ for (const imported of imports) {
86
+ const key = imported.specifier + "\u0000" + imported.source.line;
87
+ if (seen.has(key))
88
+ continue;
89
+ seen.add(key);
90
+ unique.push(imported);
91
+ }
92
+ return unique;
93
+ }
94
+ export function rawDbAccessErrors(state) {
95
+ const errors = [];
96
+ for (const facts of state.patchedFileFacts) {
97
+ const imports = statementImports(facts.imports);
98
+ for (const imported of imports) {
99
+ if (!RAW_DRIVER.test(imported.specifier) && !RAW_CLIENTS.has(imported.specifier))
100
+ continue;
101
+ errors.push(error("no-raw-db-access", facts.path, 'Importing "' + imported.specifier + '" is a raw database client outside @facilis.dev/db.', "Go through the policy-scoped db the handler receives (invariant 5).", imported.source.line));
102
+ }
103
+ const rawDrizzleImport = imports.find((imported) => imported.specifier === "drizzle-orm");
104
+ if (rawDrizzleImport !== undefined && facts.calls.some((call) => call.name === "drizzle")) {
105
+ errors.push(error("no-raw-db-access", facts.path, "Constructing a raw drizzle() client is not allowed outside @facilis.dev/db.", "Use the policy-scoped db from the handler context; import operators from drizzle-orm instead (invariant 5).", rawDrizzleImport.source.line));
106
+ }
107
+ }
108
+ return errors;
109
+ }
110
+ function fileExists(files, base) {
111
+ const target = normalizeProjectPath(base);
112
+ if (target.length === 0)
113
+ return false;
114
+ if (files.has(target))
115
+ return true;
116
+ if (HAS_EXTENSION.test(target))
117
+ return false;
118
+ for (const extension of RESOLVABLE_EXTENSIONS) {
119
+ if (files.has(target + extension))
120
+ return true;
121
+ if (files.has(target + "/index" + extension))
122
+ return true;
123
+ }
124
+ return false;
125
+ }
126
+ function relativeBase(fromPath, specifier) {
127
+ const directory = fromPath.split("/").slice(0, -1);
128
+ const segments = [...directory, ...specifier.split("/")];
129
+ const resolved = [];
130
+ for (const segment of segments) {
131
+ if (segment.length === 0 || segment === ".")
132
+ continue;
133
+ if (segment === "..") {
134
+ resolved.pop();
135
+ continue;
136
+ }
137
+ resolved.push(segment);
138
+ }
139
+ return resolved.join("/");
140
+ }
141
+ function aliasBase(specifier, aliases) {
142
+ for (const alias of aliases) {
143
+ if (!specifier.startsWith(alias.prefix))
144
+ continue;
145
+ return normalizeProjectPath(alias.target + specifier.slice(alias.prefix.length));
146
+ }
147
+ return null;
148
+ }
149
+ function stripQuery(specifier) {
150
+ const cut = specifier.search(/[?#]/);
151
+ return cut === -1 ? specifier : specifier.slice(0, cut);
152
+ }
153
+ function packageNameOf(specifier) {
154
+ const parts = specifier.split("/");
155
+ if (specifier.startsWith("@"))
156
+ return parts.slice(0, 2).join("/");
157
+ return parts[0] ?? specifier;
158
+ }
159
+ export function importResolveErrors(state, context) {
160
+ const errors = [];
161
+ for (const facts of state.patchedFileFacts) {
162
+ for (const imported of statementImports(facts.imports)) {
163
+ const specifier = stripQuery(imported.specifier);
164
+ if (specifier.length === 0)
165
+ continue;
166
+ if (specifier.startsWith("node:") || NODE_BUILTINS.has(specifier))
167
+ continue;
168
+ if (/^[a-z][a-z0-9+.-]*:/i.test(specifier))
169
+ continue;
170
+ const relative = specifier.startsWith("./") || specifier.startsWith("../") || specifier.startsWith("/");
171
+ if (relative) {
172
+ const base = specifier.startsWith("/")
173
+ ? specifier.replace(/^\/+/, "")
174
+ : relativeBase(facts.path, specifier);
175
+ if (fileExists(state.files, base))
176
+ continue;
177
+ }
178
+ else {
179
+ const alias = aliasBase(specifier, context.aliases);
180
+ if (alias !== null) {
181
+ if (fileExists(state.files, alias))
182
+ continue;
183
+ }
184
+ else if (context.packages.includes(packageNameOf(specifier))) {
185
+ continue;
186
+ }
187
+ }
188
+ errors.push(error("import-resolves", facts.path, 'Import "' + imported.specifier + '" does not resolve.', relative
189
+ ? "Point the import at a file that exists (or is created by this patch)."
190
+ : "Use a package declared in package.json; new dependencies need user approval.", imported.source.line));
191
+ }
192
+ }
193
+ return errors;
194
+ }
@@ -0,0 +1,23 @@
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
+ import type { EffectiveState } from "../overlay.js";
17
+ import type { ValidationError } from "../types.js";
18
+ export interface JobTableDeclaredFindings {
19
+ readonly errors: ValidationError[];
20
+ readonly warnings: ValidationError[];
21
+ }
22
+ /** Rule 19 over the effective analysis (SPEC §9.3, D-157 applied to jobs). */
23
+ export declare function jobTableDeclaredFindings(state: EffectiveState): JobTableDeclaredFindings;
@@ -0,0 +1,84 @@
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
+ function lineOf(source) {
17
+ return source?.line;
18
+ }
19
+ function error(file, message, fixHint, line) {
20
+ return {
21
+ rule: "job-table-declared",
22
+ severity: "error",
23
+ file,
24
+ message,
25
+ fix_hint: fixHint,
26
+ ...(line === undefined ? {} : { line }),
27
+ };
28
+ }
29
+ function unusedWarning(job, name) {
30
+ return {
31
+ rule: "job-table-declared",
32
+ severity: "warning",
33
+ file: job.sourceFile,
34
+ message: 'dbTables declares "' + name + '" but the job never uses it.',
35
+ fix_hint: 'Remove "' + name + '" from dbTables, or use it: the declaration is enforced (D-157).',
36
+ };
37
+ }
38
+ /** Opaque usage or raw SQL keeps the declaration authoritative instead of warning. */
39
+ function unusedSuppressed(usage) {
40
+ return usage.opaque || usage.rawSql.length > 0;
41
+ }
42
+ /** Rule 19 over the effective analysis (SPEC §9.3, D-157 applied to jobs). */
43
+ export function jobTableDeclaredFindings(state) {
44
+ const errors = [];
45
+ const warnings = [];
46
+ for (const job of state.analysis.jobs) {
47
+ // A hand-built analysis may predate the fact: no usage facts, no check.
48
+ const usage = state.analysis.jobRelations[job.name];
49
+ if (usage === undefined)
50
+ continue;
51
+ const declared = job.dbTables === null ? null : new Set(job.dbTables);
52
+ if (declared === null) {
53
+ errors.push(error(job.sourceFile, 'Job "' + job.name + '" dbTables is missing or cannot be read statically.', "Declare dbTables on defineJob({...}): every table or view the run body touches ([] means none).", lineOf(job.source)));
54
+ }
55
+ else {
56
+ for (const use of usage.used) {
57
+ if (declared.has(use.name))
58
+ continue;
59
+ errors.push(error(use.source.file, 'Relation "' + use.name + '" is used through the job db but not declared in dbTables.', 'Add "' +
60
+ use.name +
61
+ '" to dbTables (tables and views the run body touches), or stop using it.', lineOf(use.source)));
62
+ }
63
+ }
64
+ for (const source of usage.unresolved) {
65
+ errors.push(error(source.file, "A relation used through the job db cannot be resolved statically.", "Use a table or view binding imported from db/ instead of a dynamic expression.", lineOf(source)));
66
+ }
67
+ for (const source of usage.rawSql) {
68
+ if (job.rawSql === true)
69
+ continue;
70
+ errors.push(error(source.file, "db.execute() requires rawSql: true on the job definition.", job.rawSql === null
71
+ ? "Declare rawSql: true (or false) statically, or use the query builder."
72
+ : "Add rawSql: true to defineJob({...}), or use the query builder.", lineOf(source)));
73
+ }
74
+ if (declared !== null && !unusedSuppressed(usage)) {
75
+ const used = new Set(usage.used.map((use) => use.name));
76
+ for (const name of job.dbTables ?? []) {
77
+ if (used.has(name))
78
+ continue;
79
+ warnings.push(unusedWarning(job, name));
80
+ }
81
+ }
82
+ }
83
+ return { errors, warnings };
84
+ }
@@ -0,0 +1,20 @@
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
+ import type { EffectiveState } from "../overlay.js";
14
+ import type { ValidationError } from "../types.js";
15
+ export interface RouteTableDeclaredFindings {
16
+ readonly errors: ValidationError[];
17
+ readonly warnings: ValidationError[];
18
+ }
19
+ /** Rule 18 over the effective analysis (SPEC §9.3, D-157). */
20
+ export declare function routeTableDeclaredFindings(state: EffectiveState): RouteTableDeclaredFindings;