@facilis.dev/ai 0.1.0 → 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.
package/dist/index.d.ts CHANGED
@@ -12,5 +12,5 @@
12
12
  */
13
13
  export { buildProjectContext } from "./context.js";
14
14
  export { checkPatchSyntax } from "./syntax-runner.js";
15
- export type { Analysis, BlockFacts, ColumnFact, ConfigFacts, Diagnostic, FileFacts, FilePatch, ImportAlias, ImportFact, LocaleCatalogs, PatchContext, ProjectContext, ProjectContextInput, RouteFact, SchemaSummary, SourceRef, SyntaxCheck, SyntaxChecker, SyntaxFinding, SyntaxIssue, TableFact, TranslationCallFact, ValidatePatchOptions, ValidationError, ValidationResult, } from "./types.js";
15
+ export type { Analysis, BlockFacts, ColumnFact, ConfigFacts, Diagnostic, FileFacts, FilePatch, ImportAlias, ImportFact, LocaleCatalogs, PatchContext, ProjectContext, ProjectContextInput, RouteFact, SchemaSummary, SourceRef, SyntaxCheck, SyntaxChecker, SyntaxFinding, SyntaxIssue, TableFact, TranslationCallFact, ValidatePatchOptions, ValidationError, ValidationResult, WritePolicy, } from "./types.js";
16
16
  export { validatePatch } from "./validator.js";
package/dist/paths.d.ts CHANGED
@@ -3,20 +3,39 @@
3
3
  *
4
4
  * Paths are always compared in their normalized, root-relative form ("src/App.tsx"), so
5
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.
6
11
  */
12
+ import type { WritePolicy } from "./types.js";
7
13
  /** The project files whose facts the validator treats specially. */
8
14
  export declare const CONFIG_PATH = "facilis.config.ts";
9
15
  export declare const SCHEMA_PATH = "db/schema.ts";
10
16
  export declare const LOCALES_PATH = "locales/en.json";
11
- /** Roots a patch may write to (SPEC §9.3 rule 11). */
17
+ /** Roots a `managed` patch may write to (SPEC §9.3 rule 11, D-084). */
12
18
  export declare const ALLOWED_ROOTS: string[];
13
19
  /**
14
- * The one root file a patch may touch: rule 16 exists because a new permission/role is
15
- * declared in facilis.config.ts in the same patch, so a blanket root-file rejection would
16
- * make the 17-rule contract self-contradictory (D-084).
20
+ * The one root file a `managed` patch may touch: rule 16 exists because a new
21
+ * permission/role is declared in facilis.config.ts in the same patch, so a blanket
22
+ * root-file rejection would make the 17-rule contract self-contradictory (D-084).
17
23
  */
18
24
  export declare const ALLOWED_ROOT_FILES: Set<string>;
25
+ /**
26
+ * Directory segments no `open` patch may write into (D-167). They are the same entries
27
+ * the PTC read policy hides (docs/PTC.md §6): dependencies, VCS and IDE state, and build
28
+ * output the Explorer does not review. A patch writing them could hide its own changes,
29
+ * poison a cache, or add code without an install.
30
+ */
31
+ export declare const DENIED_SEGMENTS: Set<string>;
32
+ /**
33
+ * File names no `open` patch may write (D-167). The install surface is the dependency
34
+ * manifest, the lockfiles and the npm config; AGENTS.md stays IDE-writer-only because it
35
+ * persists instructions into every later session (D-084).
36
+ */
37
+ export declare const DENIED_FILES: Set<string>;
19
38
  export declare function normalizeProjectPath(path: string): string;
20
39
  export declare function isScriptPath(path: string): boolean;
21
- /** True when the normalized path is inside a root rule 11 allows. */
22
- export declare function isAllowedWritePath(path: string): boolean;
40
+ /** True when the normalized path is writable under `policy` (rule 11). */
41
+ export declare function isAllowedWritePath(path: string, policy?: WritePolicy): boolean;
package/dist/paths.js CHANGED
@@ -3,20 +3,53 @@
3
3
  *
4
4
  * Paths are always compared in their normalized, root-relative form ("src/App.tsx"), so
5
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.
6
11
  */
7
12
  const SCRIPT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
8
13
  /** The project files whose facts the validator treats specially. */
9
14
  export const CONFIG_PATH = "facilis.config.ts";
10
15
  export const SCHEMA_PATH = "db/schema.ts";
11
16
  export const LOCALES_PATH = "locales/en.json";
12
- /** Roots a patch may write to (SPEC §9.3 rule 11). */
17
+ /** Roots a `managed` patch may write to (SPEC §9.3 rule 11, D-084). */
13
18
  export const ALLOWED_ROOTS = ["server/", "src/", "db/", "lib/", "components/", "locales/"];
14
19
  /**
15
- * The one root file a patch may touch: rule 16 exists because a new permission/role is
16
- * declared in facilis.config.ts in the same patch, so a blanket root-file rejection would
17
- * make the 17-rule contract self-contradictory (D-084).
20
+ * The one root file a `managed` patch may touch: rule 16 exists because a new
21
+ * permission/role is declared in facilis.config.ts in the same patch, so a blanket
22
+ * root-file rejection would make the 17-rule contract self-contradictory (D-084).
18
23
  */
19
24
  export const ALLOWED_ROOT_FILES = new Set(["facilis.config.ts"]);
25
+ /**
26
+ * Directory segments no `open` patch may write into (D-167). They are the same entries
27
+ * the PTC read policy hides (docs/PTC.md §6): dependencies, VCS and IDE state, and build
28
+ * output the Explorer does not review. A patch writing them could hide its own changes,
29
+ * poison a cache, or add code without an install.
30
+ */
31
+ export const DENIED_SEGMENTS = new Set([
32
+ "node_modules",
33
+ ".git",
34
+ ".facilis",
35
+ "dist",
36
+ ".turbo",
37
+ "coverage",
38
+ ]);
39
+ /**
40
+ * File names no `open` patch may write (D-167). The install surface is the dependency
41
+ * manifest, the lockfiles and the npm config; AGENTS.md stays IDE-writer-only because it
42
+ * persists instructions into every later session (D-084).
43
+ */
44
+ export const DENIED_FILES = new Set([
45
+ "package.json",
46
+ "package-lock.json",
47
+ "npm-shrinkwrap.json",
48
+ "pnpm-lock.yaml",
49
+ "yarn.lock",
50
+ ".npmrc",
51
+ "AGENTS.md",
52
+ ]);
20
53
  export function normalizeProjectPath(path) {
21
54
  let normalized = path.trim();
22
55
  while (normalized.startsWith("./"))
@@ -26,7 +59,12 @@ export function normalizeProjectPath(path) {
26
59
  export function isScriptPath(path) {
27
60
  return SCRIPT_EXTENSIONS.some((extension) => path.endsWith(extension));
28
61
  }
29
- /** True when the normalized path is inside a root rule 11 allows. */
30
- export function isAllowedWritePath(path) {
31
- return ALLOWED_ROOT_FILES.has(path) || ALLOWED_ROOTS.some((root) => path.startsWith(root));
62
+ /** True when the normalized path is writable under `policy` (rule 11). */
63
+ export function isAllowedWritePath(path, policy = "managed") {
64
+ if (policy === "managed") {
65
+ return ALLOWED_ROOT_FILES.has(path) || ALLOWED_ROOTS.some((root) => path.startsWith(root));
66
+ }
67
+ const segments = path.split("/");
68
+ const name = segments[segments.length - 1] ?? "";
69
+ return !DENIED_FILES.has(name) && !segments.some((segment) => DENIED_SEGMENTS.has(segment));
32
70
  }
@@ -17,5 +17,8 @@ import type { FilePatch, ValidationError } from "../types.js";
17
17
  export declare function rlsErrors(state: EffectiveState): ValidationError[];
18
18
  /** Rule 14: a schema edit must ship a generated migration file (warning). */
19
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[];
20
23
  /** Rule 15: dropping a table or column is destructive and must be confirmed. */
21
24
  export declare function destructiveErrors(state: EffectiveState, requiresConfirmation: boolean): ValidationError[];
@@ -79,17 +79,22 @@ function removedColumns(before, after) {
79
79
  }
80
80
  return removed;
81
81
  }
82
- /** Rule 15: dropping a table or column is destructive and must be confirmed. */
83
- export function destructiveErrors(state, requiresConfirmation) {
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) {
84
85
  const afterNames = new Set(state.afterSchemaTables.map((table) => table.name));
85
86
  const droppedTables = state.beforeSchemaTables
86
87
  .map((table) => table.name)
87
88
  .filter((name) => !afterNames.has(name));
88
89
  const droppedColumns = removedColumns(state.beforeSchemaTables, state.afterSchemaTables);
89
- const changes = [
90
+ return [
90
91
  ...droppedTables.map((name) => 'drops table "' + name + '"'),
91
92
  ...droppedColumns.map((name) => 'drops column "' + name + '"'),
92
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);
93
98
  if (changes.length === 0 || requiresConfirmation)
94
99
  return [];
95
100
  const shown = changes.slice(0, 3).join(", ");
@@ -2,6 +2,15 @@
2
2
  * Rule 11 `file-path-allowed` plus the two structural checks the parser owns
3
3
  * (relative path, content present). These need no project context, so they also run
4
4
  * when the parser validates a raw answer before any context is assembled.
5
+ *
6
+ * The writable set follows the deployment profile (D-167): the finding keeps its rule id
7
+ * and severity, only the message and the fix hint change with the policy.
5
8
  */
6
- import type { FilePatch, ValidationError } from "../types.js";
7
- export declare function structuralErrors(patches: readonly FilePatch[], allowedRootFiles?: readonly string[]): ValidationError[];
9
+ import type { FilePatch, ValidationError, WritePolicy } from "../types.js";
10
+ export interface StructuralOptions {
11
+ /** Root files the caller's own writer may touch (never an AI patch). */
12
+ allowedRootFiles?: readonly string[];
13
+ /** Rule 11's writable set. Defaults to `managed` (fail-closed). */
14
+ writePolicy?: WritePolicy;
15
+ }
16
+ export declare function structuralErrors(patches: readonly FilePatch[], options?: StructuralOptions): ValidationError[];
@@ -2,13 +2,22 @@
2
2
  * Rule 11 `file-path-allowed` plus the two structural checks the parser owns
3
3
  * (relative path, content present). These need no project context, so they also run
4
4
  * when the parser validates a raw answer before any context is assembled.
5
+ *
6
+ * The writable set follows the deployment profile (D-167): the finding keeps its rule id
7
+ * and severity, only the message and the fix hint change with the policy.
5
8
  */
6
9
  import { isAllowedWritePath, normalizeProjectPath } from "../paths.js";
7
10
  const UNSAFE_SEGMENTS = new Set(["..", "."]);
11
+ const MANAGED_MESSAGE = " is outside the writable roots (server/, src/, db/, lib/, components/, locales/, facilis.config.ts).";
12
+ const MANAGED_HINT = "Keep the patch under server/, src/, db/, lib/, components/ or locales/; the only allowed root file is facilis.config.ts.";
13
+ const OPEN_MESSAGE = " is write-protected: a patch may not write dependencies, install state, VCS/IDE state or the project instruction file.";
14
+ const OPEN_HINT = "Choose a project source or config file; node_modules/, package.json, lockfiles, .npmrc, .git/, .facilis/, dist/, .turbo/, coverage/ and AGENTS.md are never written by a patch.";
8
15
  function error(rule, file, message, fix_hint) {
9
16
  return { rule, severity: "error", file, message, fix_hint };
10
17
  }
11
- export function structuralErrors(patches, allowedRootFiles = []) {
18
+ export function structuralErrors(patches, options = {}) {
19
+ const allowedRootFiles = options.allowedRootFiles ?? [];
20
+ const writePolicy = options.writePolicy ?? "managed";
12
21
  const errors = [];
13
22
  for (const patch of patches) {
14
23
  const path = normalizeProjectPath(patch.path);
@@ -20,10 +29,9 @@ export function structuralErrors(patches, allowedRootFiles = []) {
20
29
  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
30
  continue;
22
31
  }
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."));
32
+ if (!isAllowedWritePath(path, writePolicy) && !allowedRootFiles.includes(path)) {
33
+ const open = writePolicy === "open";
34
+ errors.push(error("file-path-allowed", patch.path, '"' + path + '"' + (open ? OPEN_MESSAGE : MANAGED_MESSAGE), open ? OPEN_HINT : MANAGED_HINT));
27
35
  }
28
36
  if (patch.action !== "delete" && (patch.content ?? "").length === 0) {
29
37
  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."));
@@ -6,7 +6,7 @@
6
6
  * patch onto the pre-patch file map and analyze the result — so every rule runs against
7
7
  * AST facts instead of a tolerant text scan.
8
8
  */
9
- import { type FilePatch, type ImportAlias, type PatchContext, type SyntaxCheck, type ValidationResult } from "../index.js";
9
+ import { type FilePatch, type ImportAlias, type PatchContext, type SyntaxCheck, type ValidatePatchOptions, type ValidationResult } from "../index.js";
10
10
  export declare const CONFIG = "import { defineConfig } from \"@facilis.dev/core\";\n\nexport default defineConfig({\n release: { ref: \"origin/main\" },\n db: { mode: \"pglite\", migrationsDir: \"db/migrations\" },\n i18n: { defaultLocale: \"en\", locales: [\"en\"] },\n auth: {\n permissions: [\"todos:read\", \"todos:write\"],\n roles: { admin: [\"*\"], user: [\"todos:read\", \"todos:write\"] },\n defaultRole: \"user\",\n },\n});\n";
11
11
  export declare const CONFIG_IT: string;
12
12
  export declare const SCHEMA = "import { boolean, pgTable, text, uuid } from \"drizzle-orm/pg-core\";\nimport { ownerIsolationPolicy, tenantColumn, tenantIsolationPolicy } from \"@facilis.dev/db\";\n\nexport const todos = pgTable(\n \"todos\",\n {\n id: uuid(\"id\").primaryKey().defaultRandom(),\n tenantId: tenantColumn(),\n ownerId: uuid(\"owner_id\").notNull(),\n title: text(\"title\").notNull(),\n done: boolean(\"done\").notNull().default(false),\n },\n () => [tenantIsolationPolicy(), ownerIsolationPolicy()],\n).enableRLS();\n";
@@ -33,6 +33,7 @@ export declare function makeContext(patches?: readonly FilePatch[], options?: Co
33
33
  export interface ValidateOptions extends ContextOptions {
34
34
  requiresConfirmation?: boolean;
35
35
  allowedRootFiles?: readonly string[];
36
+ writePolicy?: ValidatePatchOptions["writePolicy"];
36
37
  }
37
38
  /** Build the context for the patch set, then validate it (the IDE's whole round). */
38
39
  export declare function validate(patches: readonly FilePatch[], options?: ValidateOptions): {
@@ -134,6 +134,8 @@ export function validate(patches, options = {}) {
134
134
  }
135
135
  if (options.allowedRootFiles !== undefined)
136
136
  overrides.allowedRootFiles = options.allowedRootFiles;
137
+ if (options.writePolicy !== undefined)
138
+ overrides.writePolicy = options.writePolicy;
137
139
  const result = validatePatch(patches, { context, ...overrides });
138
140
  return { result, context };
139
141
  }
package/dist/types.d.ts CHANGED
@@ -16,6 +16,12 @@ export type { ColumnFact, Diagnostic, FileFacts, ImportFact, JobFact, JobRelatio
16
16
  export type ConfigFacts = AnalyzerConfigFacts;
17
17
  export type RouteFact = AnalyzerRouteFact;
18
18
  export type TableFact = AnalyzerTableFact;
19
+ /**
20
+ * Rule 11's writable set, selected by the deployment profile (D-167). `managed` keeps the
21
+ * six source roots (D-084); `open` accepts the whole project minus the install, VCS/IDE and
22
+ * instruction surfaces. The default is `managed`, so an external caller changes nothing.
23
+ */
24
+ export type WritePolicy = "open" | "managed";
19
25
  /** One proposed file write (SPEC §9.2 response contract). */
20
26
  export interface FilePatch {
21
27
  path: string;
@@ -45,6 +51,12 @@ export interface ValidationResult {
45
51
  ok: boolean;
46
52
  errors: ValidationError[];
47
53
  warnings: ValidationError[];
54
+ /**
55
+ * The patch set drops a table or a column (rule 15's predicate). The IDE's incremental
56
+ * write path records it on the turn journal; confirmation stays rule 15's business, so
57
+ * this can be true on a result that is also `ok`.
58
+ */
59
+ destructive?: boolean;
48
60
  }
49
61
  /** One parse diagnostic from the injected TypeScript service (rule 1). */
50
62
  export interface SyntaxIssue {
@@ -129,4 +141,6 @@ export interface ValidatePatchOptions {
129
141
  * AGENTS.md scaffold); AI patches never do, so the model cannot persist instructions.
130
142
  */
131
143
  allowedRootFiles?: readonly string[];
144
+ /** Rule 11's writable set (D-167). Defaults to `managed`, the strict roots. */
145
+ writePolicy?: WritePolicy;
132
146
  }
package/dist/validator.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { buildEffectiveState } from "./overlay.js";
13
13
  import { blockImportErrors } from "./rules/blocks.js";
14
14
  import { roleConfigSyncErrors } from "./rules/config-sync.js";
15
- import { destructiveErrors, migrationWarnings, rlsErrors } from "./rules/database.js";
15
+ import { destructiveChanges, destructiveErrors, migrationWarnings, rlsErrors, } from "./rules/database.js";
16
16
  import { i18nWarnings } from "./rules/i18n.js";
17
17
  import { importResolveErrors, rawDbAccessErrors } from "./rules/imports.js";
18
18
  import { jobTableDeclaredFindings } from "./rules/job-relations.js";
@@ -23,11 +23,16 @@ import { routeDbTablesPresentErrors, routeInputErrors, routeSecurityErrors, } fr
23
23
  import { structuralErrors } from "./rules/structural.js";
24
24
  import { tsParseErrors } from "./rules/ts-parse.js";
25
25
  export function validatePatch(patches, options = {}) {
26
- const errors = structuralErrors(patches, options.allowedRootFiles);
26
+ const errors = structuralErrors(patches, {
27
+ allowedRootFiles: options.allowedRootFiles ?? [],
28
+ writePolicy: options.writePolicy ?? "managed",
29
+ });
27
30
  const warnings = [];
31
+ let destructive = false;
28
32
  const context = options.context;
29
33
  if (context !== undefined) {
30
34
  const state = buildEffectiveState(context, patches);
35
+ destructive = destructiveChanges(state).length > 0;
31
36
  errors.push(...tsParseErrors(patches, context.syntax));
32
37
  errors.push(...routeSecurityErrors(state));
33
38
  errors.push(...routeDbTablesPresentErrors(state));
@@ -53,5 +58,5 @@ export function validatePatch(patches, options = {}) {
53
58
  errors.push(...jobTables.errors);
54
59
  warnings.push(...jobTables.warnings);
55
60
  }
56
- return { ok: errors.length === 0, errors, warnings };
61
+ return { ok: errors.length === 0, errors, warnings, destructive };
57
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@facilis.dev/ai",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Facilis AI orchestration: prompt assembly, response parsing and patch validation.",
6
6
  "license": "MIT",
@@ -19,7 +19,7 @@
19
19
  "node": ">=22.15.0"
20
20
  },
21
21
  "dependencies": {
22
- "@facilis.dev/analyze": "0.1.0"
22
+ "@facilis.dev/analyze": "0.1.1"
23
23
  },
24
24
  "publishConfig": {
25
25
  "access": "public"
package/src/index.ts CHANGED
@@ -39,5 +39,6 @@ export type {
39
39
  ValidatePatchOptions,
40
40
  ValidationError,
41
41
  ValidationResult,
42
+ WritePolicy,
42
43
  } from "./types.ts";
43
44
  export { validatePatch } from "./validator.ts";
package/src/paths.ts CHANGED
@@ -3,8 +3,15 @@
3
3
  *
4
4
  * Paths are always compared in their normalized, root-relative form ("src/App.tsx"), so
5
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.
6
11
  */
7
12
 
13
+ import type { WritePolicy } from "./types.ts";
14
+
8
15
  const SCRIPT_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
9
16
 
10
17
  /** The project files whose facts the validator treats specially. */
@@ -12,16 +19,46 @@ export const CONFIG_PATH = "facilis.config.ts";
12
19
  export const SCHEMA_PATH = "db/schema.ts";
13
20
  export const LOCALES_PATH = "locales/en.json";
14
21
 
15
- /** Roots a patch may write to (SPEC §9.3 rule 11). */
22
+ /** Roots a `managed` patch may write to (SPEC §9.3 rule 11, D-084). */
16
23
  export const ALLOWED_ROOTS = ["server/", "src/", "db/", "lib/", "components/", "locales/"];
17
24
 
18
25
  /**
19
- * The one root file a patch may touch: rule 16 exists because a new permission/role is
20
- * declared in facilis.config.ts in the same patch, so a blanket root-file rejection would
21
- * make the 17-rule contract self-contradictory (D-084).
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).
22
29
  */
23
30
  export const ALLOWED_ROOT_FILES = new Set(["facilis.config.ts"]);
24
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
+
25
62
  export function normalizeProjectPath(path: string): string {
26
63
  let normalized = path.trim();
27
64
  while (normalized.startsWith("./")) normalized = normalized.slice(2);
@@ -32,7 +69,12 @@ export function isScriptPath(path: string): boolean {
32
69
  return SCRIPT_EXTENSIONS.some((extension) => path.endsWith(extension));
33
70
  }
34
71
 
35
- /** True when the normalized path is inside a root rule 11 allows. */
36
- export function isAllowedWritePath(path: string): boolean {
37
- return ALLOWED_ROOT_FILES.has(path) || ALLOWED_ROOTS.some((root) => path.startsWith(root));
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));
38
80
  }
@@ -105,20 +105,26 @@ function removedColumns(before: readonly TableFact[], after: readonly TableFact[
105
105
  return removed;
106
106
  }
107
107
 
108
- /** Rule 15: dropping a table or column is destructive and must be confirmed. */
109
- export function destructiveErrors(
110
- state: EffectiveState,
111
- requiresConfirmation: boolean,
112
- ): ValidationError[] {
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[] {
113
111
  const afterNames = new Set(state.afterSchemaTables.map((table) => table.name));
114
112
  const droppedTables = state.beforeSchemaTables
115
113
  .map((table) => table.name)
116
114
  .filter((name) => !afterNames.has(name));
117
115
  const droppedColumns = removedColumns(state.beforeSchemaTables, state.afterSchemaTables);
118
- const changes = [
116
+ return [
119
117
  ...droppedTables.map((name) => 'drops table "' + name + '"'),
120
118
  ...droppedColumns.map((name) => 'drops column "' + name + '"'),
121
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);
122
128
  if (changes.length === 0 || requiresConfirmation) return [];
123
129
  const shown = changes.slice(0, 3).join(", ");
124
130
  const suffix = changes.length > 3 ? " (and " + (changes.length - 3) + " more)" : "";
@@ -2,21 +2,42 @@
2
2
  * Rule 11 `file-path-allowed` plus the two structural checks the parser owns
3
3
  * (relative path, content present). These need no project context, so they also run
4
4
  * when the parser validates a raw answer before any context is assembled.
5
+ *
6
+ * The writable set follows the deployment profile (D-167): the finding keeps its rule id
7
+ * and severity, only the message and the fix hint change with the policy.
5
8
  */
6
9
 
7
10
  import { isAllowedWritePath, normalizeProjectPath } from "../paths.ts";
8
- import type { FilePatch, ValidationError } from "../types.ts";
11
+ import type { FilePatch, ValidationError, WritePolicy } from "../types.ts";
9
12
 
10
13
  const UNSAFE_SEGMENTS = new Set(["..", "."]);
11
14
 
15
+ const MANAGED_MESSAGE =
16
+ " is outside the writable roots (server/, src/, db/, lib/, components/, locales/, facilis.config.ts).";
17
+ const MANAGED_HINT =
18
+ "Keep the patch under server/, src/, db/, lib/, components/ or locales/; the only allowed root file is facilis.config.ts.";
19
+ const OPEN_MESSAGE =
20
+ " is write-protected: a patch may not write dependencies, install state, VCS/IDE state or the project instruction file.";
21
+ const OPEN_HINT =
22
+ "Choose a project source or config file; node_modules/, package.json, lockfiles, .npmrc, .git/, .facilis/, dist/, .turbo/, coverage/ and AGENTS.md are never written by a patch.";
23
+
12
24
  function error(rule: string, file: string, message: string, fix_hint: string): ValidationError {
13
25
  return { rule, severity: "error", file, message, fix_hint };
14
26
  }
15
27
 
28
+ export interface StructuralOptions {
29
+ /** Root files the caller's own writer may touch (never an AI patch). */
30
+ allowedRootFiles?: readonly string[];
31
+ /** Rule 11's writable set. Defaults to `managed` (fail-closed). */
32
+ writePolicy?: WritePolicy;
33
+ }
34
+
16
35
  export function structuralErrors(
17
36
  patches: readonly FilePatch[],
18
- allowedRootFiles: readonly string[] = [],
37
+ options: StructuralOptions = {},
19
38
  ): ValidationError[] {
39
+ const allowedRootFiles = options.allowedRootFiles ?? [];
40
+ const writePolicy = options.writePolicy ?? "managed";
20
41
  const errors: ValidationError[] = [];
21
42
  for (const patch of patches) {
22
43
  const path = normalizeProjectPath(patch.path);
@@ -36,15 +57,14 @@ export function structuralErrors(
36
57
  );
37
58
  continue;
38
59
  }
39
- if (!isAllowedWritePath(path) && !allowedRootFiles.includes(path)) {
60
+ if (!isAllowedWritePath(path, writePolicy) && !allowedRootFiles.includes(path)) {
61
+ const open = writePolicy === "open";
40
62
  errors.push(
41
63
  error(
42
64
  "file-path-allowed",
43
65
  patch.path,
44
- '"' +
45
- path +
46
- '" is outside the writable roots (server/, src/, db/, lib/, components/, locales/, facilis.config.ts).',
47
- "Keep the patch under server/, src/, db/, lib/, components/ or locales/; the only allowed root file is facilis.config.ts.",
66
+ '"' + path + '"' + (open ? OPEN_MESSAGE : MANAGED_MESSAGE),
67
+ open ? OPEN_HINT : MANAGED_HINT,
48
68
  ),
49
69
  );
50
70
  }
@@ -176,6 +176,7 @@ export function makeContext(
176
176
  export interface ValidateOptions extends ContextOptions {
177
177
  requiresConfirmation?: boolean;
178
178
  allowedRootFiles?: readonly string[];
179
+ writePolicy?: ValidatePatchOptions["writePolicy"];
179
180
  }
180
181
 
181
182
  /** Build the context for the patch set, then validate it (the IDE's whole round). */
@@ -184,11 +185,15 @@ export function validate(
184
185
  options: ValidateOptions = {},
185
186
  ): { result: ValidationResult; context: PatchContext } {
186
187
  const context = makeContext(patches, options);
187
- const overrides: Pick<ValidatePatchOptions, "requiresConfirmation" | "allowedRootFiles"> = {};
188
+ const overrides: Pick<
189
+ ValidatePatchOptions,
190
+ "requiresConfirmation" | "allowedRootFiles" | "writePolicy"
191
+ > = {};
188
192
  if (options.requiresConfirmation !== undefined) {
189
193
  overrides.requiresConfirmation = options.requiresConfirmation;
190
194
  }
191
195
  if (options.allowedRootFiles !== undefined) overrides.allowedRootFiles = options.allowedRootFiles;
196
+ if (options.writePolicy !== undefined) overrides.writePolicy = options.writePolicy;
192
197
  const result = validatePatch(patches, { context, ...overrides });
193
198
  return { result, context };
194
199
  }
package/src/types.ts CHANGED
@@ -39,6 +39,13 @@ export type ConfigFacts = AnalyzerConfigFacts;
39
39
  export type RouteFact = AnalyzerRouteFact;
40
40
  export type TableFact = AnalyzerTableFact;
41
41
 
42
+ /**
43
+ * Rule 11's writable set, selected by the deployment profile (D-167). `managed` keeps the
44
+ * six source roots (D-084); `open` accepts the whole project minus the install, VCS/IDE and
45
+ * instruction surfaces. The default is `managed`, so an external caller changes nothing.
46
+ */
47
+ export type WritePolicy = "open" | "managed";
48
+
42
49
  /** One proposed file write (SPEC §9.2 response contract). */
43
50
  export interface FilePatch {
44
51
  path: string;
@@ -70,6 +77,12 @@ export interface ValidationResult {
70
77
  ok: boolean;
71
78
  errors: ValidationError[];
72
79
  warnings: ValidationError[];
80
+ /**
81
+ * The patch set drops a table or a column (rule 15's predicate). The IDE's incremental
82
+ * write path records it on the turn journal; confirmation stays rule 15's business, so
83
+ * this can be true on a result that is also `ok`.
84
+ */
85
+ destructive?: boolean;
73
86
  }
74
87
 
75
88
  /** One parse diagnostic from the injected TypeScript service (rule 1). */
@@ -161,4 +174,6 @@ export interface ValidatePatchOptions {
161
174
  * AGENTS.md scaffold); AI patches never do, so the model cannot persist instructions.
162
175
  */
163
176
  allowedRootFiles?: readonly string[];
177
+ /** Rule 11's writable set (D-167). Defaults to `managed`, the strict roots. */
178
+ writePolicy?: WritePolicy;
164
179
  }
package/src/validator.ts CHANGED
@@ -13,7 +13,12 @@
13
13
  import { buildEffectiveState } from "./overlay.ts";
14
14
  import { blockImportErrors } from "./rules/blocks.ts";
15
15
  import { roleConfigSyncErrors } from "./rules/config-sync.ts";
16
- import { destructiveErrors, migrationWarnings, rlsErrors } from "./rules/database.ts";
16
+ import {
17
+ destructiveChanges,
18
+ destructiveErrors,
19
+ migrationWarnings,
20
+ rlsErrors,
21
+ } from "./rules/database.ts";
17
22
  import { i18nWarnings } from "./rules/i18n.ts";
18
23
  import { importResolveErrors, rawDbAccessErrors } from "./rules/imports.ts";
19
24
  import { jobTableDeclaredFindings } from "./rules/job-relations.ts";
@@ -37,11 +42,16 @@ export function validatePatch(
37
42
  patches: readonly FilePatch[],
38
43
  options: ValidatePatchOptions = {},
39
44
  ): ValidationResult {
40
- const errors = structuralErrors(patches, options.allowedRootFiles);
45
+ const errors = structuralErrors(patches, {
46
+ allowedRootFiles: options.allowedRootFiles ?? [],
47
+ writePolicy: options.writePolicy ?? "managed",
48
+ });
41
49
  const warnings: ValidationResult["warnings"] = [];
50
+ let destructive = false;
42
51
  const context = options.context;
43
52
  if (context !== undefined) {
44
53
  const state = buildEffectiveState(context, patches);
54
+ destructive = destructiveChanges(state).length > 0;
45
55
  errors.push(...tsParseErrors(patches, context.syntax));
46
56
  errors.push(...routeSecurityErrors(state));
47
57
  errors.push(...routeDbTablesPresentErrors(state));
@@ -67,5 +77,5 @@ export function validatePatch(
67
77
  errors.push(...jobTables.errors);
68
78
  warnings.push(...jobTables.warnings);
69
79
  }
70
- return { ok: errors.length === 0, errors, warnings };
80
+ return { ok: errors.length === 0, errors, warnings, destructive };
71
81
  }