Материал FTS generation and CI: TypeScript without drift
0%

FTS generation and CI: TypeScript without drift

FTS generation and CI: TypeScript without drift

The chapter shows what exactly generates fts generate, why --check mode catches rule edits outside the model, and how to arrange the CI steps order so that cheap checks fail before expensive ones.

Minimal example

категория «Продажи»

  объект Покупка
    сумма является деньгами
    «постоянный клиент» является признаком

  утилита «Рассчитать скидку»
    принимает Покупка
    возвращает деньги
    начинает с 0

    правило «Большая покупка»
      если сумма не меньше 10000
      и сумма не больше 100000
      то добавить 10 процентов от поля сумма

    правило «Постоянный клиент»
      если «постоянный клиент» равен да
      и сумма больше 0
      и сумма не больше 100000
      то добавить 5 процентов от поля сумма

    правило «Очень крупная покупка»
      если сумма больше 100000
      то добавить 15000

    свойство «Скидка ограничена»
      результат не больше 15000

    пример «Обычная покупка»
      дано сумма равна 5000
      дано «постоянный клиент» равен нет
      ожидается результат равен 0

    пример «Постоянный клиент на пять тысяч»
      дано сумма равна 5000
      дано «постоянный клиент» равен да
      ожидается результат равен 250

    пример «Большая покупка постоянного клиента»
      дано сумма равна 20000
      дано «постоянный клиент» равен да
      ожидается результат равен 3000

    пример «Покупка на потолок скидки»
      дано сумма равна 100000
      дано «постоянный клиент» равен да
      ожидается результат равен 15000

    пример «Очень крупная покупка»
      дано сумма равна 200000
      дано «постоянный клиент» равен нет
      ожидается результат равен 15000

This is the static/fts/models/order-discount.fts model, on which examples/fts/discount-api and examples/fts/typescript-codegen are built.

What the compiler does

fts generate first runs assertValid(compile(source)), then generateTypeScript(document). The result is not abstract code, but a direct transliteration of the model’s rules. For the utility above, the generated fts.utilities.ts looks like this:

// Generated by FTS. Do not edit by hand.

export interface FtsInput0 {
  "сумма": number
  "постоянный клиент": boolean
}

export const ftsUtilities = {
  "Рассчитать скидку": (input: FtsInput0): number => {
    let result: number = 0
    if (input["сумма"] >= 10000 && input["сумма"] <= 100000) {
      result += (10 / 100) * input["сумма"]
    }
    if (input["постоянный клиент"] === true && input["сумма"] > 0 && input["сумма"] <= 100000) {
      result += (5 / 100) * input["сумма"]
    }
    if (input["сумма"] > 100000) {
      result += 15000
    }
    if (!(result <= 15000)) throw new Error("Нарушено свойство «Скидка ограничена»")
    return result
  },
} as const

Each если/то becomes if, each процент от поля — arithmetic, each свойство — runtime check with throw. The second file, fts.utilities.test.ts, — is a regular node:test with one test(...) per пример: five examples in the model give five generated tests.

--check mode and how drift is caught

examples/fts/typescript-codegen/generate.mjs wraps generation in writeGenerated. In --check mode the function does not write files, but compares already generated code with the content on disk and collects a list of “outdated” files:

node examples/fts/typescript-codegen/generate.mjs           # записать generated/
node examples/fts/typescript-codegen/generate.mjs --check   # сверить с моделью

If someone modified result += 50 / 100 * ... directly in .ts, rather than in .fts, --check will detect the mismatch and exit with code 1:

Сгенерированный код отстал от модели: fts.utilities.ts
Запустите: node examples/fts/typescript-codegen/generate.mjs

generate.test.mjs checks this with a separate test: it writes the generated code to a temporary directory, then overwrites fts.utilities.ts with the string // правка мимо модели and verifies that --check returns stale: ['fts.utilities.ts']. The second test protects against the reverse error — generation from a model that fails its own examples: if the expectation 3000 is replaced with 4200, generate() throws не проходит свои примеры: 2/3 even before calling generateTypeScript. Code generation makes no sense on top of a model that does not converge with itself.

Order of steps in CI and what fails first

The ready workflow is located in examples/fts/ci/fts-check.yml (placed in .github/workflows/fts-check.yml):

- run: npm ci
- run: npx fts check static/fts/models/order-discount.fts
- run: npx fts test static/fts/models/order-discount.fts --pretty
- run: node examples/fts/typescript-codegen/generate.mjs --check
- run: node --test "examples/fts/**/*.test.mjs"

The order is not accidental — each step is cheaper and more specific than the next:

  1. fts check — syntax and types: typo in a keyword or reference to a non-existent field fails in milliseconds, never reaching the rule logic.
  2. fts test --pretty — concrete examples: the rule compiles, but gives the wrong number. This is the first place where a content-related business logic fallacy fails.
  3. generate.mjs --check — drift between the model and committed TypeScript. The model is already correct at this stage; the error means that generation was forgotten to be restarted after editing .fts.
  4. node --test examples/fts/**/*.test.mjs — integrations: HTTP service, command guard, form. The most expensive and farthest from the model layer fails last.

If the rule in .fts is changed without running fts generate, the third stage will catch the discrepancy before the code reaches production — this is exactly what generate.test.mjs checks.

Agent Work

MCP tool fts_generate is designed more strictly than CLI: it has no --out parameter, and it always returns { generation: generateTypeScript(...) } as structured content, writing nothing to the disk. This is an intentional limitation — the agent can suggest generation for viewing, but file writing and commit remain a separate controlled step performed by a person or pipeline, not the tool invocation itself.

Practice in the sandbox

Change the percentage in the “Big Purchase” rule and see how the generated ftsUtilities changes.

Common Fallacies

  • FTS_NO_UTILITY_EXAMPLES — the utility has no example at all. fts generate and fts test require at least one example: without it, there is nothing to run as node:test, and .ts-implementation without a check is no more than an unproven promise.
  • FTS_UTILITY_PROPERTY — the generated function and the interpreter throw an error in the same way if the result violates свойство. In CI, this will be a failure of the generated test, not a silently incorrect number.
  • FTS_UTILITY_INPUT_TYPE — the utility input does not match the field type (for example, a string instead of a number). The diagnostics are the same whether during fts run or when calling ftsUtilities[...] from the generated code: the source of types is the model, not a manual TypeScript signature.

Checklist

  • Model .fts goes through fts check and fts test before generation.
  • fts generate --out is called after each rule edit.
  • CI compares the committed code through --check, rather than trusting that the developer did not forget to regenerate.
  • The order of steps in CI — from cheap syntax checks to expensive integrations.
  • A pull request with .fts edit shows a small readable model diff, rather than just a large diff of the generated file.

Cases in the catalog on this topic

Next: performance

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше