@telepath-computer/fslint
v0.3.3
Published
Declarative linter for a directory of files. Structural validation for agents managing files.
Readme
fslint
⚠️ Experimental. Early-stage software. The config schema, CLI, and API may change without notice between releases. Pin exact versions if you depend on it.
A declarative linter for a directory of files. Rules live in a
.fslint.yml at the root and are grouped by scopes — a
where: predicate block (path glob and/or frontmatter values),
optionally narrowed by a segment (a slice of content within each
file). Running fslint walks the root, matches scopes, extracts
segments, and runs each scope's rules.
Report-only. No auto-fix, no daemon, no plugins — just a config, a walk, and violations printed to the terminal.
Why
Expected use case: agents managing files. When LLM-driven tools or scripts need to read, mutate, and write files in a directory, the structure has to be verifiable. fslint runs as a gate — locally, in CI, in pre-commit — to catch files that drift from the agreed-upon shape. The rule set (kebab-case paths, typed frontmatter, segment invariants, structural heading rules) targets the kinds of things an agent needs to parse reliably, not the kinds of things humans care about for readability.
Why not ?
- markdownlint is a formatting linter — line lengths, list markers, consistent emphasis style. It does not validate frontmatter schemas, filenames, folder structure, or content within a specific section.
- Vale is a prose / style linter — grammar, passive voice, terminology consistency. A different axis entirely.
- remark-lint is powerful but AST-based and plugin-assembled — you wire up a pipeline from the unified ecosystem. fslint trades flexibility for a single config with a small set of predictable rules.
- ls-lint validates filenames and directory names but doesn't look at file contents. fslint covers both paths and content in one pass.
- ESLint and friends target JavaScript / TypeScript source. Different problem.
fslint sits in the gap: structural validation across a directory of files, scoped by globs and segments, producing violations a script (human or agent) can act on.
Install
Requires Node 20+.
git clone <this repo>
cd notelint
npm install
npm run build
npm link # makes `fslint` available on your PATHQuick start
# .fslint.yml at the root of the directory you want to lint
ignore:
- ".obsidian/**"
- ".trash/**"
scopes:
- where:
path: "**/*.md"
rules:
- type: headings
min_level: 2
blank_before: 1
blank_after: 1
- type: filename
case: kebab-case
- where:
kind: folder
path: "**"
rules:
- type: folder
case: kebab-caseThen:
fslint .Exits non-zero on violations; prints per-file lists with rule ids.
Rules
Each rule's full config and semantics live in docs/rules/<name>.md.
| Rule | Target | Purpose |
|---|---|---|
| frontmatter | file | JSON-Schema-style validation of YAML frontmatter — properties, types, case: on strings, array items + style: flow \| block, additional_properties, required per key, plus blank_after spacing |
| headings | file | ATX headings: blank_before / blank_after exact counts, min_level |
| checkbox_list | file | Body (or segment slice) must be GFM task-list items + blanks only |
| filename | file | Checks the file's own basename against a case: preset |
| indent | file | Leading whitespace style — style: tab \| space and width: for space |
| folder | folder | Checks each directory's own name against a case: preset |
Shared case presets (used by filename, folder, and frontmatter's
string case:): kebab-case, snake_case, camelCase, PascalCase,
lower.
Scopes
Each scope filters via a where: predicate block whose keys are ANDed
together. File scopes (the default, kind: file) run file-level rules
against every markdown file that matches. Folder scopes (kind: folder)
run folder-level rules against every directory that matches, via a
separate directory walk.
scopes:
# File scope, whole-file rules — `kind: file` is the default.
- where:
path: "**/*.md"
rules: [ ... ]
# File scope narrowed to a frontmatter type — predicate values are
# equality checks against parsed YAML scalars; multiple keys AND.
- where:
path: "**/*.md"
frontmatter:
type: person
rules: [ ... ]
# File scope narrowed to the `## Actions` segment of matching files
- where:
path: "projects/**/*.md"
segment:
starts_at: { heading: "Actions" }
ends_at: { same_or_higher_heading: true }
rules:
- type: checkbox_list
# Folder scope
- where:
kind: folder
path: "**"
rules: [ ... ]Predicate kinds in v1: kind: (file/folder, default file), path:
(string glob), frontmatter: (file scopes only — map of literal-equality
checks against scalar YAML values). An absent or empty where: matches
every walked path. frontmatter: on a folder scope is a config error.
Rule-target mismatch (e.g. a file rule in a folder scope) is also a config error.
Segment boundaries supported in v0.1: heading, same_or_higher_heading.
Library use
lint() is pure: it takes a root and a Config. Build the config
yourself, or use loadConfig / findConfig for the disk flow.
import { lint, findConfig, loadConfig } from "fslint";
// Inline config — no disk needed
const result = await lint("./my-project", {
ignore: [".obsidian/**"],
scopes: [
{
kind: "file",
where: { path: "**/*.md" },
rules: [
{
id: "heading-spacing",
type: "headings",
blankBefore: 1,
blankAfter: 1,
},
],
},
],
});
// Or, disk-based: the same flow the CLI uses
const configPath = await findConfig("./my-project");
if (!configPath) throw new Error("no .fslint.yml");
const config = await loadConfig(configPath);
const result2 = await lint("./my-project", config);Status
v0.1 / pre-release. API and config shape may still change. Tests cover the runner, each rule's happy path and edge cases, and one end-to-end CLI test.
Run npm test for the suite. npm run type-check for types only.
