# rules/eslint/no-raw-file-write.eslint.rule.ts

> 95 lines of code and 20 definitions.

Tree: Governance tree
Language: typescript
Canonical: https://banes-lab.com/anatomy/governance#file-governance-rules-eslint-no-raw-file-write-eslint-rule-ts
Source text: https://banes-lab.com/source/governance/rules/eslint/no-raw-file-write.eslint.rule.ts.txt

Listed in [rules/eslint](https://banes-lab.com/api/source/governance/rules/eslint.md), after [rules/eslint/no-raw-dom.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/no-raw-dom.eslint.rule.ts.md) and before [rules/eslint/no-redundant-type-shim.eslint.rule.ts](https://banes-lab.com/source/governance/rules/eslint/no-redundant-type-shim.eslint.rule.ts.md).

## Definitions

- `reportWrite` (lexical_declaration, line 46)
- `reportSpacing` (lexical_declaration, line 31)
- `handleCall` (lexical_declaration, line 68)
- `resolve` (lexical_declaration, line 55)
- `REQUIRED_SPACING` (lexical_declaration, line 18)
- `SPACING_ARG_INDEX` (lexical_declaration, line 19)
- `TAG` (lexical_declaration, line 21)
- `BYPASS` (lexical_declaration, line 23)
- `ENCODING` (lexical_declaration, line 25)
- `MARKER` (lexical_declaration, line 27)
- `SPACING` (lexical_declaration, line 29)
- `third` (lexical_declaration, line 32)
- `spacing` (lexical_declaration, line 33)
- `primitive` (lexical_declaration, line 47)
- `target` (lexical_declaration, line 51)
- `call` (lexical_declaration, line 69)
- `rule` (lexical_declaration, line 80)
- `create` (method_definition, line 81)
- `ownership` (lexical_declaration, line 82)
- `handlers` (lexical_declaration, line 86)

## Uses

- [shared/analyzers/generator.analyzer.ts](https://banes-lab.com/source/governance/shared/analyzers/generator.analyzer.ts.md)
- [shared/analyzers/sink.analyzer.ts](https://banes-lab.com/source/governance/shared/analyzers/sink.analyzer.ts.md)
- [shared/resolvers/scope.resolver.ts](https://banes-lab.com/source/governance/shared/resolvers/scope.resolver.ts.md)

## Records this file grounds

- [Idempotency](https://banes-lab.com/records/architecture/idempotency.md)

## Records this file checks

- [Idempotency](https://banes-lab.com/records/architecture/idempotency.md)
- [Determinism](https://banes-lab.com/records/architecture/determinism.md)

## Source

```typescript
import type { InitResolver, WriteNode, WriteOwnership, WriteSite } from "../../types/generator.types.ts";
import { WRITER_FUNCTIONS, WRITE_OWNER_MODULES } from "../../shared/manifests/generator.manifest.ts";
import { WRITER_OPTIONS_SCHEMA, declaredModules, declaredWriters } from "../../shared/allowlists/factory.allowlist.ts";
import { asWriteNode, variableInit } from "../../shared/resolvers/scope.resolver.ts";
import {
    calledName,
    isFileWriteCall,
    isJsonStringify,
    parentWriteMissingUtf8,
} from "../../shared/analyzers/sink.analyzer.ts";
import { rootsAtEphemeralDir, targetIsUnmarkedGenerated } from "../../shared/analyzers/generator.analyzer.ts";
import type { Rule } from "eslint";
import { basenameOf } from "../../shared/resolvers/anchor.resolver.ts";
import { defineCheck } from "@govlab/context/check";

defineCheck({ detects: [], enforces: ["architecture:idempotency", "architecture:determinism"] });

const REQUIRED_SPACING = 4;
const SPACING_ARG_INDEX = 2;

const TAG = "[no_raw_file_write]";

const BYPASS = `A file is written through the raw file-write primitive outside the module that owns file writes, so the write skips what the owner guarantees: generated output lands in canonical bytes, and a verbatim write never lands on a file marked as generated. Write through the owner's API, or declare this module as an owner when it is the one place its member writes files. ${TAG}`;

const ENCODING = `File write of JSON.stringify output is missing utf-8 encoding. Pass "utf-8" to the write call so the file is encoded deterministically. ${TAG}`;

const MARKER = `A generated file written to a formatter-supported path carries no generated marker in its filename. Output produced by code carries the marker, so the tooling can find it and the developer never edits it by hand, and the conventional readme is the one exception. Rename the write target so the marker comes before its extension. ${TAG}`;

const SPACING = `JSON.stringify indent is {{spacing}}, not 4. Use JSON.stringify(value, null, 4) so serialized output is consistent across the codebase. ${TAG}`;

const reportSpacing = function reportSpacing(context: Rule.RuleContext, call: WriteNode, node: Rule.Node): void {
    const third = call.arguments?.[SPACING_ARG_INDEX];
    const spacing = third?.type === "Literal" && typeof third.value === "number" ? third.value : null;
    if (spacing === null) {
        return;
    }
    if (spacing !== REQUIRED_SPACING) {
        context.report({ data: { spacing: String(spacing) }, messageId: "spacing", node });
        return;
    }
    if (parentWriteMissingUtf8(call)) {
        context.report({ messageId: "encoding", node });
    }
};

const reportWrite = function reportWrite(context: Rule.RuleContext, ownership: WriteOwnership, site: WriteSite): void {
    const primitive = isFileWriteCall(site.call);
    if (!primitive && !ownership.writers.has(calledName(site.call) ?? "")) {
        return;
    }
    const target = site.call.arguments?.[0];
    if (!target) {
        return;
    }
    const resolve: InitResolver = (name) => variableInit(context, site.node, name);
    if (rootsAtEphemeralDir(resolve, target, 0)) {
        return;
    }
    if (primitive && !ownership.owner) {
        context.report({ messageId: "bypass", node: site.node });
        return;
    }
    if (targetIsUnmarkedGenerated(resolve, target)) {
        context.report({ messageId: "marker", node: site.node });
    }
};

const handleCall = function handleCall(context: Rule.RuleContext, ownership: WriteOwnership, node: Rule.Node): void {
    const call = asWriteNode(node);
    if (call === null) {
        return;
    }
    if (isJsonStringify(call)) {
        reportSpacing(context, call, node);
        return;
    }
    reportWrite(context, ownership, { call, node });
};

const rule: Rule.RuleModule = {
    create(context: Rule.RuleContext): Rule.RuleListener {
        const ownership: WriteOwnership = {
            owner: declaredModules(context, WRITE_OWNER_MODULES).has(basenameOf(context.filename)),
            writers: declaredWriters(context, WRITER_FUNCTIONS),
        };
        const handlers: [string, (node: Rule.Node) => void][] = [
            [
                "CallExpression",
                (node): void => {
                    handleCall(context, ownership, node);
                },
            ],
        ];
        return Object.fromEntries(handlers);
    },
    meta: {
        docs: {
            description:
                "Every file write goes through the module that owns file writes, and the raw file-write primitive is banned everywhere else. The owner writes generated output in canonical bytes and refuses a verbatim write to a file marked as generated, so a write helper cannot carry output past the check. A formatter-supported output path carries the generated marker, and serialized JSON keeps one indent and one encoding. Which modules own writes, and which of their functions write, is data in the rule's options.",
        },
        messages: { bypass: BYPASS, encoding: ENCODING, marker: MARKER, spacing: SPACING },
        schema: WRITER_OPTIONS_SCHEMA,
        type: "problem",
    },
};

export default { plugins: { "govlab-write": { rules: { "no-raw-file-write": rule } } }, tool: "eslint" };
```
