@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,54 @@
1
+ /**
2
+ * Defensive reader for the live `/__facilis/manifest` payload (SPEC §6.2).
3
+ *
4
+ * Only two things still come from the manifest: the registered blocks (reviewed supply
5
+ * chain, not a source fact) and a locale-catalogue fallback. Config, schema and routes are
6
+ * read from @facilis.dev/analyze's source facts (D-092); the manifest is data from a running
7
+ * app, not a trusted compiler output, so an unexpected shape yields fewer facts and never
8
+ * an exception.
9
+ */
10
+
11
+ import type { BlockFacts } from "./types.ts";
12
+
13
+ export interface ManifestFacts {
14
+ readonly blocks: readonly BlockFacts[];
15
+ readonly locales: Readonly<Record<string, string>>;
16
+ }
17
+
18
+ function isRecord(value: unknown): value is Record<string, unknown> {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+
22
+ function factsFromBlocks(value: unknown): BlockFacts[] {
23
+ if (!Array.isArray(value)) return [];
24
+ return value.flatMap((entry) =>
25
+ isRecord(entry) && typeof entry.name === "string" ? [{ name: entry.name }] : [],
26
+ );
27
+ }
28
+
29
+ /** Prefer the default locale, fall back to English, then to the first object catalogue. */
30
+ function factsFromCatalog(value: unknown, defaultLocale: string | null): Record<string, string> {
31
+ if (!isRecord(value)) return {};
32
+ const preferred = defaultLocale !== null && isRecord(value[defaultLocale]) ? defaultLocale : "en";
33
+ const locale = isRecord(value[preferred])
34
+ ? preferred
35
+ : Object.keys(value).find((candidate) => isRecord(value[candidate]));
36
+ const catalog = locale === undefined ? undefined : value[locale];
37
+ if (!isRecord(catalog)) return {};
38
+ const strings: Record<string, string> = {};
39
+ for (const [key, text] of Object.entries(catalog)) {
40
+ if (typeof text === "string") strings[key] = text;
41
+ }
42
+ return strings;
43
+ }
44
+
45
+ /** Read whatever the manifest is allowed to contribute; a broken payload yields zero facts. */
46
+ export function factsFromManifest(manifest: unknown): ManifestFacts {
47
+ const root = isRecord(manifest) ? manifest : {};
48
+ const i18n = isRecord(root.i18n) ? root.i18n : {};
49
+ const defaultLocale = typeof i18n.defaultLocale === "string" ? i18n.defaultLocale : null;
50
+ return {
51
+ blocks: factsFromBlocks(root.blocks),
52
+ locales: factsFromCatalog(i18n.catalogs, defaultLocale),
53
+ };
54
+ }
package/src/node.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Node-only helpers, exported as `@facilis.dev/ai/node` (D-084, D-092).
3
+ *
4
+ * The main entry must stay browser-safe, so this subpath is the only place that imports
5
+ * runtime code from a parser: rule 1's rolldown `parseSync` (oxc) and, for tests/tooling,
6
+ * @facilis.dev/analyze's oxc adapter so a project file map can be analyzed without a browser.
7
+ * The IDE never imports this module: it injects the analyzer worker's oxc parser instead.
8
+ */
9
+
10
+ import { analyzeProject, type Analysis, type PathAlias } from "@facilis.dev/analyze";
11
+ import { createOxcParser as createAnalyzerParser } from "@facilis.dev/analyze/node";
12
+ import { parseSync } from "rolldown/utils";
13
+
14
+ import type { SyntaxChecker, SyntaxIssue } from "./types.ts";
15
+
16
+ export type ScriptLanguage = "ts" | "tsx" | "js" | "jsx";
17
+
18
+ interface ParserError {
19
+ severity: string;
20
+ message: string;
21
+ labels?: readonly { start: number; end: number }[];
22
+ }
23
+
24
+ function languageOf(path: string): ScriptLanguage {
25
+ if (path.endsWith(".tsx")) return "tsx";
26
+ if (path.endsWith(".jsx")) return "jsx";
27
+ if (path.endsWith(".js") || path.endsWith(".mjs") || path.endsWith(".cjs")) return "js";
28
+ return "ts";
29
+ }
30
+
31
+ function positionOf(content: string, offset: number): { line: number; column: number } {
32
+ const bounded = Math.max(0, Math.min(offset, content.length));
33
+ const lines = content.slice(0, bounded).split("\n");
34
+ return { line: lines.length, column: (lines.at(-1)?.length ?? 0) + 1 };
35
+ }
36
+
37
+ function toIssue(content: string, error: ParserError): SyntaxIssue {
38
+ const label = error.labels?.[0];
39
+ const position = positionOf(content, label?.start ?? 0);
40
+ return { line: position.line, column: position.column, message: error.message };
41
+ }
42
+
43
+ /** A {@link SyntaxChecker} backed by rolldown's oxc parser (Node only). */
44
+ export function createTypeScriptSyntaxChecker(): SyntaxChecker {
45
+ return {
46
+ async check(path, content) {
47
+ const language = languageOf(path);
48
+ try {
49
+ const result = parseSync(path, content, { lang: language });
50
+ return (result.errors as readonly ParserError[])
51
+ .filter((error) => error.severity === "Error")
52
+ .map((error) => toIssue(content, error));
53
+ } catch (error) {
54
+ const message = error instanceof Error ? error.message : String(error);
55
+ return [{ line: 1, column: 1, message }];
56
+ }
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Analyze a project file map with the Node oxc parser. Tests/tooling only: the browser
63
+ * passes its own parser adapter to `analyzeProject`, and the main entry never imports
64
+ * this module (it would pull a parser into the IDE bundle).
65
+ */
66
+ export function analyzeProjectSources(
67
+ files: ReadonlyMap<string, string>,
68
+ aliases?: readonly PathAlias[],
69
+ ): Analysis {
70
+ return analyzeProject({
71
+ files,
72
+ parser: createAnalyzerParser(),
73
+ ...(aliases === undefined ? {} : { aliases }),
74
+ });
75
+ }
package/src/overlay.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The pre-patch project context overlaid with one patch set.
3
+ *
4
+ * After D-092 this is fact-based: the caller supplies the effective (post-patch) Analysis
5
+ * and the pre-patch one, so the overlay only projects them plus the patch-shaped
6
+ * bookkeeping the rules need (file set, patched paths, removed facts). It never parses
7
+ * project sources; the one JSON read is the patched locale catalogue, because rule 13 must
8
+ * tell "no keys" from "broken catalogue". Pure and browser-safe.
9
+ */
10
+
11
+ import type { Analysis, ConfigFacts, FileFacts, TableFact } from "@facilis.dev/analyze";
12
+
13
+ import { LOCALES_PATH, normalizeProjectPath, SCHEMA_PATH } from "./paths.ts";
14
+ import type { FilePatch, PatchContext } from "./types.ts";
15
+
16
+ export { CONFIG_PATH, LOCALES_PATH, SCHEMA_PATH } from "./paths.ts";
17
+
18
+ export interface EffectiveState {
19
+ /** Project-relative files that exist after the patch. */
20
+ files: ReadonlySet<string>;
21
+ /** Every path the patch touches, and those it deletes. */
22
+ patchedPaths: ReadonlySet<string>;
23
+ deletedPaths: ReadonlySet<string>;
24
+ /** Effective analysis + the pre-patch one, for inverse edits. */
25
+ analysis: Analysis;
26
+ before: Analysis;
27
+ /** Module facts of the created/updated files (from the effective analysis). */
28
+ patchedFileFacts: readonly FileFacts[];
29
+ /** Effective config after the patch (analyzer facts). */
30
+ config: ConfigFacts;
31
+ /** Every effective table, including framework tables re-exported into the schema. */
32
+ schemaTables: readonly TableFact[];
33
+ /** Tables declared in the code schema before and after the patch (no re-exports). */
34
+ beforeSchemaTables: readonly TableFact[];
35
+ afterSchemaTables: readonly TableFact[];
36
+ locales: Readonly<Record<string, string>>;
37
+ /** False when a patched locales/en.json is not a valid JSON catalogue. */
38
+ localesValid: boolean;
39
+ migrationsDir: string;
40
+ removedTables: readonly string[];
41
+ removedPermissions: readonly string[];
42
+ }
43
+
44
+ function parseCatalog(content: string): Record<string, string> | null {
45
+ try {
46
+ const parsed: unknown = JSON.parse(content);
47
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
48
+ const catalog: Record<string, string> = {};
49
+ for (const [key, value] of Object.entries(parsed)) {
50
+ if (typeof value === "string") catalog[key] = value;
51
+ }
52
+ return catalog;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /** A table the project declares in its own schema file (not a framework re-export). */
59
+ function isSchemaTable(table: TableFact): boolean {
60
+ return normalizeProjectPath(table.source.file) === SCHEMA_PATH;
61
+ }
62
+
63
+ /** True when the patch creates a SQL migration file in the configured directory. */
64
+ export function hasMigrationPatch(patches: readonly FilePatch[], migrationsDir: string): boolean {
65
+ const directory = normalizeProjectPath(migrationsDir).replace(/\/+$/, "");
66
+ return patches.some(
67
+ (patch) =>
68
+ patch.action === "create" &&
69
+ normalizeProjectPath(patch.path).startsWith(directory + "/") &&
70
+ normalizeProjectPath(patch.path).endsWith(".sql"),
71
+ );
72
+ }
73
+
74
+ /** Overlay the patch set onto the project context. */
75
+ export function buildEffectiveState(
76
+ context: PatchContext,
77
+ patches: readonly FilePatch[],
78
+ ): EffectiveState {
79
+ const patched = new Map<string, string>();
80
+ const deleted = new Set<string>();
81
+ const patchedPaths = new Set<string>();
82
+ for (const patch of patches) {
83
+ const path = normalizeProjectPath(patch.path);
84
+ patchedPaths.add(path);
85
+ if (patch.action === "delete") deleted.add(path);
86
+ else if (typeof patch.content === "string") patched.set(path, patch.content);
87
+ }
88
+
89
+ const analysis = context.analysis;
90
+ const before = context.before;
91
+ const config = analysis.config;
92
+ const beforeSchemaTables = before.tables.filter(isSchemaTable);
93
+ const afterSchemaTables = analysis.tables.filter(isSchemaTable);
94
+ const afterNames = new Set(afterSchemaTables.map((table) => table.name));
95
+ const beforeNames = new Set(beforeSchemaTables.map((table) => table.name));
96
+
97
+ let locales = context.locales;
98
+ let localesValid = true;
99
+ const catalogContent = patched.get(LOCALES_PATH);
100
+ if (catalogContent !== undefined) {
101
+ const catalog = parseCatalog(catalogContent);
102
+ if (catalog === null) localesValid = false;
103
+ else locales = catalog;
104
+ }
105
+
106
+ const files = new Set(context.files.map(normalizeProjectPath));
107
+ for (const path of deleted) files.delete(path);
108
+ for (const path of patched.keys()) files.add(path);
109
+
110
+ return {
111
+ files,
112
+ patchedPaths,
113
+ deletedPaths: deleted,
114
+ analysis,
115
+ before,
116
+ patchedFileFacts: analysis.fileFacts.filter((facts) =>
117
+ patchedPaths.has(normalizeProjectPath(facts.path)),
118
+ ),
119
+ config,
120
+ schemaTables: analysis.tables,
121
+ beforeSchemaTables,
122
+ afterSchemaTables,
123
+ locales,
124
+ localesValid,
125
+ migrationsDir: normalizeProjectPath(config.migrationsDir),
126
+ removedTables: [...beforeNames].filter((name) => !afterNames.has(name)),
127
+ removedPermissions: before.config.permissions.filter(
128
+ (permission) => !config.permissions.includes(permission),
129
+ ),
130
+ };
131
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Project-relative path predicates shared by the validator rules (SPEC §9.3 rule 11).
3
+ *
4
+ * Paths are always compared in their normalized, root-relative form ("src/App.tsx"), so
5
+ * Windows separators or a leading "./" never change a rule's verdict.
6
+ *
7
+ * Rule 11's writable set follows the deployment profile (D-167). A `managed` build keeps
8
+ * the six source roots (D-084); an `open` build accepts the whole project minus the
9
+ * machine surfaces a patch must never touch: dependencies, install state, VCS/IDE state,
10
+ * build output and the project's own instruction file.
11
+ */
12
+
13
+ import type { WritePolicy } from "./types.ts";
14
+
15
+ const SCRIPT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
16
+
17
+ /** The project files whose facts the validator treats specially. */
18
+ export const CONFIG_PATH = "facilis.config.ts";
19
+ export const SCHEMA_PATH = "db/schema.ts";
20
+ export const LOCALES_PATH = "locales/en.json";
21
+
22
+ /** Roots a `managed` patch may write to (SPEC §9.3 rule 11, D-084). */
23
+ export const ALLOWED_ROOTS = ["server/", "src/", "db/", "lib/", "components/", "locales/"];
24
+
25
+ /**
26
+ * The one root file a `managed` patch may touch: rule 16 exists because a new
27
+ * permission/role is declared in facilis.config.ts in the same patch, so a blanket
28
+ * root-file rejection would make the 17-rule contract self-contradictory (D-084).
29
+ */
30
+ export const ALLOWED_ROOT_FILES = new Set(["facilis.config.ts"]);
31
+
32
+ /**
33
+ * Directory segments no `open` patch may write into (D-167). They are the same entries
34
+ * the PTC read policy hides (docs/PTC.md §6): dependencies, VCS and IDE state, and build
35
+ * output the Explorer does not review. A patch writing them could hide its own changes,
36
+ * poison a cache, or add code without an install.
37
+ */
38
+ export const DENIED_SEGMENTS = new Set([
39
+ "node_modules",
40
+ ".git",
41
+ ".facilis",
42
+ "dist",
43
+ ".turbo",
44
+ "coverage",
45
+ ]);
46
+
47
+ /**
48
+ * File names no `open` patch may write (D-167). The install surface is the dependency
49
+ * manifest, the lockfiles and the npm config; AGENTS.md stays IDE-writer-only because it
50
+ * persists instructions into every later session (D-084).
51
+ */
52
+ export const DENIED_FILES = new Set([
53
+ "package.json",
54
+ "package-lock.json",
55
+ "npm-shrinkwrap.json",
56
+ "pnpm-lock.yaml",
57
+ "yarn.lock",
58
+ ".npmrc",
59
+ "AGENTS.md",
60
+ ]);
61
+
62
+ export function normalizeProjectPath(path: string): string {
63
+ let normalized = path.trim();
64
+ while (normalized.startsWith("./")) normalized = normalized.slice(2);
65
+ return normalized.replace(/^\/+/, "");
66
+ }
67
+
68
+ export function isScriptPath(path: string): boolean {
69
+ return SCRIPT_EXTENSIONS.some((extension) => path.endsWith(extension));
70
+ }
71
+
72
+ /** True when the normalized path is writable under `policy` (rule 11). */
73
+ export function isAllowedWritePath(path: string, policy: WritePolicy = "managed"): boolean {
74
+ if (policy === "managed") {
75
+ return ALLOWED_ROOT_FILES.has(path) || ALLOWED_ROOTS.some((root) => path.startsWith(root));
76
+ }
77
+ const segments = path.split("/");
78
+ const name = segments[segments.length - 1] ?? "";
79
+ return !DENIED_FILES.has(name) && !segments.some((segment) => DENIED_SEGMENTS.has(segment));
80
+ }
@@ -0,0 +1,67 @@
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
+
11
+ import type { EffectiveState } from "../overlay.ts";
12
+ import type { ImportFact, PatchContext, ValidationError } from "../types.ts";
13
+
14
+ const DEFAULT_BLOCKS_PACKAGE = "@facilis.dev/blocks";
15
+
16
+ /** The import names a block import introduces: named imports and default-import locals. */
17
+ function namesOf(imported: ImportFact): string[] {
18
+ if (imported.kind === "named" && imported.imported !== null) return [imported.imported];
19
+ if (imported.kind === "default" && imported.local !== null) return [imported.local];
20
+ return [];
21
+ }
22
+
23
+ /** The block name an import points at, or null when the specifier is not a block import. */
24
+ function blockNameFromPath(specifier: string, packages: ReadonlySet<string>): string | null {
25
+ for (const pkg of packages) {
26
+ if (specifier === pkg) return null;
27
+ if (specifier.startsWith(pkg + "/")) {
28
+ const name = specifier.slice(pkg.length + 1).split("/")[0];
29
+ return name === undefined || name.length === 0 ? null : name;
30
+ }
31
+ }
32
+ const segments = specifier.split("/");
33
+ const index = segments.indexOf("blocks");
34
+ if (index === -1) return null;
35
+ const name = segments[index + 1];
36
+ return name === undefined || name.length === 0 ? null : name;
37
+ }
38
+
39
+ export function blockImportErrors(state: EffectiveState, context: PatchContext): ValidationError[] {
40
+ // Blocks are no longer configurable: the framework ships one reviewed package (D-067).
41
+ const packages = new Set([DEFAULT_BLOCKS_PACKAGE]);
42
+ const known = new Set(context.blocks.map((block) => block.name));
43
+ const errors: ValidationError[] = [];
44
+ const seen = new Set<string>();
45
+ for (const facts of state.patchedFileFacts) {
46
+ for (const imported of facts.imports) {
47
+ const fromPackageRoot = packages.has(imported.specifier);
48
+ const pathName = blockNameFromPath(imported.specifier, packages);
49
+ const candidates = fromPackageRoot ? namesOf(imported) : pathName === null ? [] : [pathName];
50
+ for (const candidate of candidates) {
51
+ const key = facts.path + "\u0000" + candidate;
52
+ if (known.has(candidate) || seen.has(key)) continue;
53
+ seen.add(key);
54
+ errors.push({
55
+ rule: "block-import-valid",
56
+ severity: "error",
57
+ file: facts.path,
58
+ message: 'Block import "' + candidate + '" does not match a registered BlockManifest.',
59
+ fix_hint:
60
+ "Import an existing block manifest, or stop importing it; do not create new blocks unprompted (SPEC §9.2 rule 5).",
61
+ line: imported.source.line,
62
+ });
63
+ }
64
+ }
65
+ }
66
+ return errors;
67
+ }
@@ -0,0 +1,72 @@
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
+
8
+ import { hasMigrationPatch, CONFIG_PATH, type EffectiveState } from "../overlay.ts";
9
+ import { normalizeProjectPath } from "../paths.ts";
10
+ import type { ConfigFacts, FilePatch, ValidationError } from "../types.ts";
11
+
12
+ function changedFacts(before: ConfigFacts, after: ConfigFacts): string[] {
13
+ const changes: string[] = [];
14
+ for (const permission of after.permissions) {
15
+ if (!before.permissions.includes(permission))
16
+ changes.push('adds permission "' + permission + '"');
17
+ }
18
+ for (const permission of before.permissions) {
19
+ if (!after.permissions.includes(permission))
20
+ changes.push('removes permission "' + permission + '"');
21
+ }
22
+ const beforeRoles = JSON.stringify(before.roles);
23
+ const afterRoles = JSON.stringify(after.roles);
24
+ if (beforeRoles !== afterRoles) changes.push("changes auth.roles");
25
+ if ((before.defaultRole ?? "") !== (after.defaultRole ?? "")) {
26
+ changes.push('changes defaultRole to "' + (after.defaultRole ?? "") + '"');
27
+ }
28
+ return changes;
29
+ }
30
+
31
+ export function roleConfigSyncErrors(
32
+ patches: readonly FilePatch[],
33
+ state: EffectiveState,
34
+ ): ValidationError[] {
35
+ const touchesConfig = patches.some((patch) => {
36
+ return normalizeProjectPath(patch.path) === CONFIG_PATH && patch.action !== "delete";
37
+ });
38
+ if (!touchesConfig) return [];
39
+ const errors: ValidationError[] = [];
40
+ const after = state.config;
41
+ // A config patch the scan cannot read is unverifiable: rule 16 fails closed instead of
42
+ // assuming "no auth change". The pre-patch source may be unparseable for the same
43
+ // reason, so the check is on the patched content itself.
44
+ if (!after.parsed) {
45
+ errors.push({
46
+ rule: "role-config-sync",
47
+ severity: "error",
48
+ file: CONFIG_PATH,
49
+ message:
50
+ "The auth block of the patched facilis.config.ts could not be read, so the role/permission sync cannot be verified.",
51
+ fix_hint:
52
+ "Keep the auth declaration as an object literal inside defineConfig so the validator can read it.",
53
+ });
54
+ return errors;
55
+ }
56
+ const changes = changedFacts(state.before.config, after);
57
+ if (changes.length === 0 || hasMigrationPatch(patches, state.migrationsDir)) return [];
58
+ errors.push({
59
+ rule: "role-config-sync",
60
+ severity: "error",
61
+ file: CONFIG_PATH,
62
+ message:
63
+ "The patch " +
64
+ changes.slice(0, 3).join(", ") +
65
+ " but ships no migration updating role_permissions.",
66
+ fix_hint:
67
+ "Add a migration under " +
68
+ state.migrationsDir +
69
+ "/ that reseeds role_permissions from the config (SPEC §5.3, rule 16).",
70
+ });
71
+ return errors;
72
+ }
@@ -0,0 +1,139 @@
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
+
10
+ import type { EffectiveState } from "../overlay.ts";
11
+ import { hasMigrationPatch, SCHEMA_PATH } from "../overlay.ts";
12
+ import { normalizeProjectPath } from "../paths.ts";
13
+ import type { FilePatch, TableFact, ValidationError } from "../types.ts";
14
+
15
+ function error(rule: string, file: string, message: string, fix_hint: string): ValidationError {
16
+ return { rule, severity: "error", file, message, fix_hint };
17
+ }
18
+
19
+ function warning(rule: string, file: string, message: string, fix_hint: string): ValidationError {
20
+ return { rule, severity: "warning", file, message, fix_hint };
21
+ }
22
+
23
+ /** True when this patch set touches the project schema file (create, update or delete). */
24
+ function touchesSchema(state: EffectiveState): boolean {
25
+ return state.patchedPaths.has(SCHEMA_PATH) || state.deletedPaths.has(SCHEMA_PATH);
26
+ }
27
+
28
+ function normalized(value: string): string {
29
+ return value.replaceAll("_", "").replaceAll("-", "").toLowerCase();
30
+ }
31
+
32
+ /** A table is scoped when the analyzer saw a scope or a configured tenant/owner column. */
33
+ function isScoped(
34
+ table: TableFact,
35
+ tenantColumns: readonly string[],
36
+ ownerColumns: readonly string[],
37
+ ): boolean {
38
+ if (table.scope !== null) return true;
39
+ const names = new Set([...tenantColumns, ...ownerColumns].map(normalized));
40
+ return table.columns.some(
41
+ (column) => names.has(normalized(column.property)) || names.has(normalized(column.name)),
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Rule 10: a schema rewrite is a complete re-emission of the schema, so every scoped table
47
+ * in the result must ship its RLS policy — .enableRLS() plus a policy (invariant 4). The
48
+ * check is not limited to new tables: dropping the policy from an existing scoped table is
49
+ * the same security regression.
50
+ */
51
+ export function rlsErrors(state: EffectiveState): ValidationError[] {
52
+ if (!touchesSchema(state)) return [];
53
+ const errors: ValidationError[] = [];
54
+ for (const table of state.afterSchemaTables) {
55
+ if (!isScoped(table, state.config.tenantColumns, state.config.ownerColumns)) continue;
56
+ if (table.rlsEnabled && table.policies.length > 0) continue;
57
+ errors.push(
58
+ error(
59
+ "rls-on-scoped-table",
60
+ SCHEMA_PATH,
61
+ 'Scoped table "' + table.name + '" has no RLS policy in the patched db/schema.ts.',
62
+ "Add .enableRLS() and a tenantIsolationPolicy()/ownerIsolationPolicy() (or pgPolicy) to the table in db/schema.ts (invariant 4).",
63
+ ),
64
+ );
65
+ }
66
+ return errors;
67
+ }
68
+
69
+ /** Rule 14: a schema edit must ship a generated migration file (warning). */
70
+ export function migrationWarnings(
71
+ patches: readonly FilePatch[],
72
+ state: EffectiveState,
73
+ ): ValidationError[] {
74
+ const touches = patches.some((patch) => {
75
+ const path = normalizeProjectPath(patch.path);
76
+ return path === SCHEMA_PATH && patch.action !== "delete";
77
+ });
78
+ if (!touches) return [];
79
+ if (hasMigrationPatch(patches, state.migrationsDir)) return [];
80
+ return [
81
+ warning(
82
+ "migration-generated",
83
+ SCHEMA_PATH,
84
+ "The patch changes the schema without shipping a migration.",
85
+ "Add the drizzle-kit generated migration (.sql) under " +
86
+ state.migrationsDir +
87
+ "/ in the same patch.",
88
+ ),
89
+ ];
90
+ }
91
+
92
+ function removedColumns(before: readonly TableFact[], after: readonly TableFact[]): string[] {
93
+ const afterByName = new Map(after.map((table) => [table.name, table]));
94
+ const removed: string[] = [];
95
+ for (const table of before) {
96
+ const current = afterByName.get(table.name);
97
+ if (current === undefined || table.columns.length === 0 || current.columns.length === 0) {
98
+ continue;
99
+ }
100
+ const currentColumns = new Set(current.columns.map((column) => column.property));
101
+ for (const column of table.columns) {
102
+ if (!currentColumns.has(column.property)) removed.push(table.name + "." + column.property);
103
+ }
104
+ }
105
+ return removed;
106
+ }
107
+
108
+ /** The schema removals in a patch set (rule 15's predicate), for callers that need the
109
+ * fact without the finding (the IDE flags a write destructive, never blocks it). */
110
+ export function destructiveChanges(state: EffectiveState): string[] {
111
+ const afterNames = new Set(state.afterSchemaTables.map((table) => table.name));
112
+ const droppedTables = state.beforeSchemaTables
113
+ .map((table) => table.name)
114
+ .filter((name) => !afterNames.has(name));
115
+ const droppedColumns = removedColumns(state.beforeSchemaTables, state.afterSchemaTables);
116
+ return [
117
+ ...droppedTables.map((name) => 'drops table "' + name + '"'),
118
+ ...droppedColumns.map((name) => 'drops column "' + name + '"'),
119
+ ];
120
+ }
121
+
122
+ /** Rule 15: dropping a table or column is destructive and must be confirmed. */
123
+ export function destructiveErrors(
124
+ state: EffectiveState,
125
+ requiresConfirmation: boolean,
126
+ ): ValidationError[] {
127
+ const changes = destructiveChanges(state);
128
+ if (changes.length === 0 || requiresConfirmation) return [];
129
+ const shown = changes.slice(0, 3).join(", ");
130
+ const suffix = changes.length > 3 ? " (and " + (changes.length - 3) + " more)" : "";
131
+ return [
132
+ error(
133
+ "destructive-confirmed",
134
+ SCHEMA_PATH,
135
+ "Destructive schema change without confirmation: the patch " + shown + suffix + ".",
136
+ "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).",
137
+ ),
138
+ ];
139
+ }
@@ -0,0 +1,42 @@
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
+
8
+ import { LOCALES_PATH, type EffectiveState } from "../overlay.ts";
9
+ import { isScriptPath } from "../paths.ts";
10
+ import type { ValidationError } from "../types.ts";
11
+
12
+ export function i18nWarnings(state: EffectiveState): ValidationError[] {
13
+ const warnings: ValidationError[] = [];
14
+ if (!state.localesValid) {
15
+ warnings.push({
16
+ rule: "i18n-keys-registered",
17
+ severity: "warning",
18
+ file: LOCALES_PATH,
19
+ message:
20
+ "The patched locales/en.json is not a valid JSON catalogue, so i18n keys cannot be verified.",
21
+ fix_hint: "Emit locales/en.json as a complete JSON object of key/value strings.",
22
+ });
23
+ }
24
+ const known = new Set(Object.keys(state.locales));
25
+ for (const facts of state.patchedFileFacts) {
26
+ if (!isScriptPath(facts.path)) continue;
27
+ const reported = new Set<string>();
28
+ for (const call of facts.translationCalls) {
29
+ if (known.has(call.key) || reported.has(call.key)) continue;
30
+ reported.add(call.key);
31
+ warnings.push({
32
+ rule: "i18n-keys-registered",
33
+ severity: "warning",
34
+ file: facts.path,
35
+ message: 't("' + call.key + '") is not registered in locales/en.json.',
36
+ fix_hint: "Add the key to locales/en.json in the same patch, or reuse a registered key.",
37
+ line: call.source.line,
38
+ });
39
+ }
40
+ }
41
+ return warnings;
42
+ }