patdown 0.3.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 (54) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +201 -0
  3. package/dist/cli.d.ts +12 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +85 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +6 -0
  9. package/dist/markdown-squint-rule-parser.d.ts +7 -0
  10. package/dist/markdown-squint-rule-parser.d.ts.map +1 -0
  11. package/dist/markdown-squint-rule-parser.js +85 -0
  12. package/dist/patdown-cli-bin.d.ts +3 -0
  13. package/dist/patdown-cli-bin.d.ts.map +1 -0
  14. package/dist/patdown-cli-bin.js +4 -0
  15. package/dist/patdown-cli-version.d.ts +3 -0
  16. package/dist/patdown-cli-version.d.ts.map +1 -0
  17. package/dist/patdown-cli-version.js +18 -0
  18. package/dist/patdown-judge.d.ts +30 -0
  19. package/dist/patdown-judge.d.ts.map +1 -0
  20. package/dist/patdown-judge.js +26 -0
  21. package/dist/patdown-lint.d.ts +7 -0
  22. package/dist/patdown-lint.d.ts.map +1 -0
  23. package/dist/patdown-lint.js +89 -0
  24. package/dist/patdown-output.d.ts +27 -0
  25. package/dist/patdown-output.d.ts.map +1 -0
  26. package/dist/patdown-output.js +45 -0
  27. package/dist/patdown-package-config.d.ts +15 -0
  28. package/dist/patdown-package-config.d.ts.map +1 -0
  29. package/dist/patdown-package-config.js +123 -0
  30. package/dist/patdown-question-input.d.ts +5 -0
  31. package/dist/patdown-question-input.d.ts.map +1 -0
  32. package/dist/patdown-question-input.js +21 -0
  33. package/dist/patdown-rule-source-adapter.d.ts +10 -0
  34. package/dist/patdown-rule-source-adapter.d.ts.map +1 -0
  35. package/dist/patdown-rule-source-adapter.js +83 -0
  36. package/dist/patdown-yes-threshold-config.d.ts +7 -0
  37. package/dist/patdown-yes-threshold-config.d.ts.map +1 -0
  38. package/dist/patdown-yes-threshold-config.js +20 -0
  39. package/dist/run-patdown-cli.d.ts +12 -0
  40. package/dist/run-patdown-cli.d.ts.map +1 -0
  41. package/dist/run-patdown-cli.js +17 -0
  42. package/dist/squint-lint.d.ts +7 -0
  43. package/dist/squint-lint.d.ts.map +1 -0
  44. package/dist/squint-lint.js +80 -0
  45. package/dist/squint-rule-source.d.ts +34 -0
  46. package/dist/squint-rule-source.d.ts.map +1 -0
  47. package/dist/squint-rule-source.js +78 -0
  48. package/dist/squint-rule.d.ts +17 -0
  49. package/dist/squint-rule.d.ts.map +1 -0
  50. package/dist/squint-rule.js +2 -0
  51. package/dist/typesafe-judge.d.ts +8 -0
  52. package/dist/typesafe-judge.d.ts.map +1 -0
  53. package/dist/typesafe-judge.js +14 -0
  54. package/package.json +83 -0
package/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Zero-Clause BSD
2
+ =============
3
+
4
+ Permission to use, copy, modify, and/or distribute this software for
5
+ any purpose with or without fee is hereby granted.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
8
+ WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
9
+ OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
10
+ FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
11
+ DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
12
+ AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
13
+ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # patdown
2
+
3
+ Standalone CLI that lints a tree against fuzzy rules in one markdown file. Wrap it as a hook, plugin, or extension.
4
+
5
+ The judge is swappable. The default backend currently uses TypeSafe/Jev; rules and CLI commands use a provider-neutral interface.
6
+
7
+ ## Install
8
+
9
+ ```
10
+ npx patdown --help
11
+ pnpm add -D patdown
12
+ ```
13
+
14
+ Requires Node.js >=22.22.2. The library entry is `patdown`; rule adapters import `@patdown/rules`.
15
+
16
+ ## Requirements
17
+
18
+ Node.js **>=22.22.2** and pnpm **11.8.0**. CI runs checks and built-CLI smoke tests on Node 22.22.2 and Node 24. Releases through v0.2.2 require Node >=24.18.0 and use `#/` import aliases that Node 22 rejects.
19
+
20
+ ## Commands
21
+
22
+ ```
23
+ pnpm -w patdown
24
+ pnpm -w patdown -- --rules ./rules.md
25
+ pnpm -w patdown -- rules
26
+ pnpm -w patdown -- ask "Is this markdown heading title case?" --input-text "# Hello World"
27
+ pnpm -w patdown -- ask "Is this urgent?" --input-text "ASAP" --verbose
28
+ pnpm -w patdown -- --yes-threshold 0.9
29
+ ```
30
+
31
+ Default command lints from the current directory. `rules` prints what it loaded. `ask` answers a yes/no question, no files involved. It prints `yes` or `no`; `--verbose` also shows the estimated probability of yes and the cutoff. The old `--noul` and `--state` flags have been replaced by a positional question and `--input-text`.
32
+
33
+ To judge piped output, use `--stdin`. For example, after building with `pnpm -w build` or running any `pnpm -w patdown` command:
34
+
35
+ ```sh
36
+ git diff --cached | node apps/patdown/dist/patdown-cli-bin.js ask "Does this diff introduce debugging statements?" --stdin
37
+ printf 'ASAP: production is down\n' | node apps/patdown/dist/patdown-cli-bin.js ask "Is this urgent?" --stdin
38
+ ```
39
+
40
+ These send the piped content to the configured judge. Do not pipe secrets. `--stdin` reads UTF-8 text through EOF, preserving newlines; it cannot be combined with `--input-text`. Without either option, the input is an empty string. `ask` reports yes/no without treating yes as a failing exit status.
41
+
42
+ Walks up from cwd looking for `AGENTS.PATDOWN.md`. `--rules` skips that walk and uses the path you pass.
43
+
44
+ ## Adapters
45
+
46
+ The default rule source parses markdown. Swap it with a module that exports `PatdownRuleSourceLive`, an Effect Layer for `PatdownRuleSource`.
47
+
48
+ ```
49
+ pnpm -w patdown -- --adapter ./patdown-yaml-rules.js
50
+ ```
51
+
52
+ Or in the nearest `package.json` walking up from cwd:
53
+
54
+ ```
55
+ {
56
+ "patdown": {
57
+ "adapter": "./patdown-yaml-rules.js",
58
+ "yesThreshold": 0.9
59
+ }
60
+ }
61
+ ```
62
+
63
+ `--adapter` wins over package.json. `--rules` is still passed to the adapter as an override path.
64
+
65
+ ```
66
+ import { PatdownRuleSource } from '@patdown/rules'
67
+ import { Effect, Layer } from 'effect'
68
+
69
+ export const PatdownRuleSourceLive = Layer.succeed(PatdownRuleSource, {
70
+ loadPatdownRules: () =>
71
+ Effect.succeed({
72
+ patdownRules: [
73
+ {
74
+ patdownRuleTitle: 'No title case',
75
+ patdownRuleBody: 'Headings use sentence case.',
76
+ patdownRuleGlobs: ['**/*.md'],
77
+ },
78
+ ],
79
+ patdownRulesFilePath: 'yaml-rules',
80
+ }),
81
+ })
82
+ ```
83
+
84
+ Load files however you want inside `loadPatdownRules`. `@patdown/rules` exports `findPatdownRulesFilePath` if you still want to walk up for a filename.
85
+
86
+ If you wrap the CLI as a hook and already have a layer, skip discovery:
87
+
88
+ ```
89
+ import { Effect } from 'effect'
90
+ import { runPatdownCli } from 'patdown'
91
+
92
+ await Effect.runPromise(runPatdownCli(PatdownRuleSourceLive))
93
+ ```
94
+
95
+ See [the adapter guide](docs/rule-source-adapters.md) for the interface, a multi-file parser, resolution rules, resource lifetimes, and local development setup. Adapters execute trusted local code. Install the CLI from npm as `patdown`; adapters import `@patdown/rules` and may import `patdown` for types.
96
+
97
+ ## Rules
98
+
99
+ One `# heading` per rule. Optional `globs:` and `yes-threshold:` lines sit immediately under the heading, in either order. Commas or spaces, extra `globs:` lines stack. A second `yes-threshold:` line is an error. Text above the first heading is ignored. Headings inside fenced code are ignored. Only `#` headings start rules; `##` and deeper headings stay in the rule body, so sections like `## Not allowed` and `## Exceptions` are fine.
100
+
101
+ ```
102
+ # No title case
103
+ globs: **/*.md
104
+ yes-threshold: 0.9
105
+
106
+ Markdown headings must use sentence case, not title case.
107
+ ```
108
+
109
+ No globs means `**/*`. Globs are relative to cwd, not to the rules file. Always skipped: `.git`, `.turbo`, `coverage`, `dist`, `node_modules`.
110
+
111
+ ## Lint
112
+
113
+ Each matched file goes to the judge as "does this file violate the following patdown rule?" The evaluated text is `path:` plus the file contents. One file at a time.
114
+
115
+ A rule with no matches prints `patdown: no files matched ...` and does not fail.
116
+
117
+ ```
118
+ FAIL README.md: No title case
119
+ patdown: failed
120
+ ```
121
+
122
+ Exit 1 on a violation, a missing rules file, a read error, an invalid cutoff, or a judge error. Add `--verbose` to show probabilities. Patdown counts estimated P(yes) strictly above the cutoff as yes; for lint, yes means violation. Default cutoff is 0.85. Override it with `--yes-threshold`, package.json `patdown.yesThreshold`, or a per-rule `yes-threshold:` line. The flag wins over package.json; a per-rule value wins for that rule only. `1` is rejected because nothing can exceed it. This cutoff belongs to patdown, not the provider.
123
+
124
+ See [judge providers](docs/judge-providers.md) for custom layers and the planned Effect Decision integration.
125
+
126
+ ## Large inputs and API errors
127
+
128
+ Jev has a token budget, not a fixed safe diff size. Direct testing of `jev-1.13.0` accepted a 96,768-byte synthetic diff but rejected 97,536 bytes with HTTP 400 and `max_tokens_exceeded`; a larger, low-token input still succeeded. Different text, questions, and models can move that boundary.
129
+
130
+ The Jev client reports HTTP status, recognized provider error codes, input byte count, and a TypeSafe request ID when available. It distinguishes token limits from HTTP payload, authentication, rate/quota, and server failures without printing raw response bodies. It does not silently truncate input.
131
+
132
+ See [the measured results and live probe commands](docs/jev-input-limits.md), including how to compare a separate Vercel AI Gateway integration.
133
+
134
+ ## Custom output
135
+
136
+ Embedded callers can replace `PatdownOutput` instead of using the default yes/no and lint formatting. Pass an output Layer as the fourth argument to `runPatdownCli`:
137
+
138
+ ```ts
139
+ import { Console, Effect, Layer } from 'effect'
140
+ import { PatdownOutput, patdownJudgmentIsYes, runPatdownCli } from 'patdown'
141
+
142
+ const JsonOutputLive = Layer.succeed(PatdownOutput, {
143
+ writeAnswer: (judgment, _verbose) =>
144
+ Console.log(
145
+ JSON.stringify({
146
+ answer: patdownJudgmentIsYes(judgment) ? 'yes' : 'no',
147
+ yesProbability: judgment.yesProbability,
148
+ }),
149
+ ),
150
+ writeLintResult: (result, _verbose) => Console.log(JSON.stringify(result)),
151
+ writeRulesDocument: (document) => Console.log(JSON.stringify(document)),
152
+ writeNoFilesMatched: (ruleTitle) => Console.log(JSON.stringify({ skipped: ruleTitle })),
153
+ writeLintOk: Console.log(JSON.stringify({ status: 'ok' })),
154
+ writeLintFailed: Console.log(JSON.stringify({ status: 'failed' })),
155
+ })
156
+
157
+ await Effect.runPromise(
158
+ runPatdownCli(
159
+ undefined, // default rule-source discovery
160
+ process.argv.slice(2),
161
+ undefined, // default judge
162
+ JsonOutputLive,
163
+ ),
164
+ )
165
+ ```
166
+
167
+ The output service receives structured judgments and lint results, including probabilities even when `--verbose` is off. Your layer decides what to print, collect, or omit. Formatting does not change the cutoff or exit status. The example emits one JSON object per output event, not a single JSON document for the entire run.
168
+
169
+ This is an embedding API, not a `--format` flag or dynamically discovered output plugin. Supply any dependencies inside your output layer; its effects must handle their own failures. CLI help, argument errors, and loading/provider errors still use the CLI's existing help/stderr paths rather than this service. Install `patdown` from npm for embeddings. Custom adapters still need a matching Effect version.
170
+
171
+ ## Env
172
+
173
+ With the default TypeSafe backend, `TYPESAFE_API_KEY` is required for lint and `ask`. Optional `TYPESAFE_BASE_URL` (default `https://api.typesafe.ai`) and `TYPESAFE_DEFAULT_MODEL` (default `jev-latest`).
174
+
175
+ `pnpm -w patdown` forwards `TYPESAFE_*` through Turbo.
176
+
177
+ ## Release
178
+
179
+ Version lives in `apps/patdown/package.json`. That is what `patdown --version` prints.
180
+
181
+ ```
182
+ pnpm -w release patch
183
+ pnpm -w release minor
184
+ pnpm -w release major
185
+ ```
186
+
187
+ First write and commit `releases/vX.Y.Z.md` with the next version's notes. The release command requires a clean tree and validates those notes before changing anything. It runs `pnpm check`, bumps the CLI version, commits, tags `vX.Y.Z`, and pushes to `github` and `gitea` if present. With `gh` available, it watches the matching Release workflow.
188
+
189
+ The tag workflow runs checks again, creates a GitHub Release using the checked-in notes, and publishes `patdown`, `@patdown/rules`, and `@patdown/jev` to npm. See [the release process](releases/README.md) for the metadata format and backfilling published notes.
190
+
191
+ Pull requests and pushes to `main` run `pnpm check`. That is oxlint, tests, and typecheck. Not the fuzzy linter.
192
+
193
+ ## Related
194
+
195
+ Inspired by [pi-warden](https://github.com/DevMortimer/pi-warden). Same idea, inside pi.
196
+
197
+ [Abide](https://github.com/coldteadotai/abide) is a similar Jev-backed checker. It hooks into coding agents, reads project instruction files, and asks Jev whether each edit or turn broke a rule.
198
+
199
+ Name inspired by It's Always Sunny in Philadelphia
200
+
201
+ <img width="511" height="415" alt="image" src="https://github.com/user-attachments/assets/f7c73138-3914-4fbd-9e6b-7a37d161334a" />
package/dist/cli.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { PatdownRuleSource } from '@patdown/rules';
2
+ import { FileSystem, Path, Stdio } from 'effect';
3
+ import { Command } from 'effect/unstable/cli';
4
+ import { PatdownJudge } from '#src/patdown-judge';
5
+ import { PatdownOutput } from '#src/patdown-output';
6
+ type PatdownLintServices = FileSystem.FileSystem | Stdio.Stdio | PatdownJudge | Path.Path | PatdownOutput | PatdownRuleSource;
7
+ /** Builds commands with optional adapter discovery for embedded callers. */
8
+ export declare function makePatdownCommand(discoverAdapters?: boolean): Command.Command<'patdown', never, object, never, PatdownLintServices>;
9
+ /** Default commands use explicit or package.json adapter discovery. */
10
+ export declare const patdownCommand: Command.Command<"patdown", never, object, never, PatdownLintServices>;
11
+ export {};
12
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,iBAAiB,EAKjB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAU,UAAU,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,QAAQ,CAAA;AACxD,OAAO,EAAY,OAAO,EAAQ,MAAM,qBAAqB,CAAA;AAE7D,OAAO,EAAE,YAAY,EAAuC,MAAM,oBAAoB,CAAA;AAEtF,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AA4BnD,KAAK,mBAAmB,GACrB,UAAU,CAAC,UAAU,GACrB,KAAK,CAAC,KAAK,GACX,YAAY,GACZ,IAAI,CAAC,IAAI,GACT,aAAa,GACb,iBAAiB,CAAA;AAmBpB,4EAA4E;AAC5E,wBAAgB,kBAAkB,CACjC,gBAAgB,GAAE,OAAc,GAC9B,OAAO,CAAC,OAAO,CAAC,SAAS,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,mBAAmB,CAAC,CA6GvE;AAED,uEAAuE;AACvE,eAAO,MAAM,cAAc,uEAAuB,CAAA"}
package/dist/cli.js ADDED
@@ -0,0 +1,85 @@
1
+ import { PatdownRuleSource, PatdownRulesFileMissing, PatdownRulesReadFailed, PatdownRulesLoadFailed, PatdownYesThresholdInvalid, } from '@patdown/rules';
2
+ import { Effect, FileSystem, Path, Stdio } from 'effect';
3
+ import { Argument, Command, Flag } from 'effect/unstable/cli';
4
+ import { PatdownJudge, PatdownJudgeFailed, askPatdownJudge } from '#src/patdown-judge';
5
+ import { runPatdownLint } from '#src/patdown-lint';
6
+ import { PatdownOutput } from '#src/patdown-output';
7
+ import { readPatdownQuestionInput } from '#src/patdown-question-input';
8
+ import { loadConfiguredPatdownRules } from '#src/patdown-rule-source-adapter';
9
+ import { resolvePatdownYesThreshold } from '#src/patdown-yes-threshold-config';
10
+ const failPatdown = (message) => Effect.sync(() => {
11
+ process.exitCode = 1;
12
+ process.stderr.write(`${message}\n`);
13
+ });
14
+ const rulesFileFlag = Flag.optional(Flag.string('rules')).pipe(Flag.withDescription('Path passed to the rule source. Markdown skips the AGENTS.PATDOWN.md walk'));
15
+ const verboseFlag = Flag.boolean('verbose').pipe(Flag.withDefault(false), Flag.withDescription('Show estimated yes probabilities and the decision cutoff'));
16
+ const adapterFlag = Flag.optional(Flag.string('adapter')).pipe(Flag.withDescription('Module exporting PatdownRuleSourceLive, replacingkdown rule parsing'));
17
+ const yesThresholdFlag = Flag.optional(Flag.float('yes-threshold')).pipe(Flag.withDescription('Minimum exclusive P(yes) for yes; default 0.85, overridable per rule'));
18
+ function finishPatdownLint(failed) {
19
+ return Effect.gen(function* () {
20
+ const output = yield* PatdownOutput;
21
+ if (failed) {
22
+ yield* output.writeLintFailed;
23
+ yield* Effect.sync(() => {
24
+ process.exitCode = 1;
25
+ });
26
+ return;
27
+ }
28
+ yield* output.writeLintOk;
29
+ });
30
+ }
31
+ /** Builds commands with optional adapter discovery for embedded callers. */
32
+ export function makePatdownCommand(discoverAdapters = true) {
33
+ const rulesCommand = Command.make('rules', { adapter: adapterFlag, rules: rulesFileFlag }, ({ rules, adapter, }) => Effect.gen(function* () {
34
+ const patdownRuleSource = yield* PatdownRuleSource;
35
+ const output = yield* PatdownOutput;
36
+ const document = yield* discoverAdapters
37
+ ? loadConfiguredPatdownRules(adapter, rules)
38
+ : patdownRuleSource.loadPatdownRules(rules);
39
+ yield* output.writeRulesDocument(document);
40
+ }).pipe(Effect.catchTags({
41
+ PatdownRulesFileMissing: (error) => failPatdown(error.message),
42
+ PatdownRulesReadFailed: (error) => failPatdown(error.message),
43
+ PatdownRulesLoadFailed: (error) => failPatdown(error.message),
44
+ PatdownYesThresholdInvalid: (error) => failPatdown(error.message),
45
+ }))).pipe(Command.withDescription('Load and print patdown rules'));
46
+ const askCommand = Command.make('ask', {
47
+ question: Argument.string('question'),
48
+ verbose: verboseFlag,
49
+ yesThreshold: yesThresholdFlag,
50
+ inputText: Flag.optional(Flag.string('input-text')).pipe(Flag.withDescription('Text to evaluate')),
51
+ stdin: Flag.boolean('stdin').pipe(Flag.withDefault(false), Flag.withDescription('Read UTF-8 text from piped or redirected stdin')),
52
+ }, ({ question, inputText, stdin, verbose, yesThreshold, }) => Effect.gen(function* () {
53
+ const output = yield* PatdownOutput;
54
+ const text = yield* readPatdownQuestionInput(inputText, stdin);
55
+ const cutoff = yield* resolvePatdownYesThreshold(yesThreshold);
56
+ const answer = yield* askPatdownJudge(question, text);
57
+ yield* output.writeAnswer(answer, verbose, cutoff);
58
+ }).pipe(Effect.catchTags({
59
+ PatdownJudgeFailed: (error) => failPatdown(error.message),
60
+ PatdownYesThresholdInvalid: (error) => failPatdown(error.message),
61
+ }))).pipe(Command.withDescription('Ask a yes/no question about text'));
62
+ /** Root Effect CLI command for patdown. Default action lints files against AGENTS.PATDOWN.md. */
63
+ return Command.make('patdown', {
64
+ adapter: adapterFlag,
65
+ rules: rulesFileFlag,
66
+ verbose: verboseFlag,
67
+ yesThreshold: yesThresholdFlag,
68
+ }, ({ rules, adapter, verbose, yesThreshold }) => Effect.gen(function* () {
69
+ const patdownRuleSource = yield* PatdownRuleSource;
70
+ const cutoff = yield* resolvePatdownYesThreshold(yesThreshold);
71
+ const document = yield* discoverAdapters
72
+ ? loadConfiguredPatdownRules(adapter, rules)
73
+ : patdownRuleSource.loadPatdownRules(rules);
74
+ const failed = yield* runPatdownLint(document, verbose, cutoff);
75
+ yield* finishPatdownLint(failed);
76
+ }).pipe(Effect.catchTags({
77
+ PatdownRulesLoadFailed: (error) => failPatdown(error.message),
78
+ PatdownJudgeFailed: (error) => failPatdown(error.message),
79
+ PatdownYesThresholdInvalid: (error) => failPatdown(error.message),
80
+ PatdownRulesFileMissing: (error) => failPatdown(error.message),
81
+ PatdownRulesReadFailed: (error) => failPatdown(error.message),
82
+ }))).pipe(Command.withDescription('Lint a tree against fuzzykdown rules'), Command.withShortDescription('Patdown CLI'), Command.withSubcommands([askCommand, rulesCommand]));
83
+ }
84
+ /** Default commands use explicit or package.json adapter discovery. */
85
+ export const patdownCommand = makePatdownCommand();
@@ -0,0 +1,8 @@
1
+ export { patdownCommand, makePatdownCommand } from '#src/cli';
2
+ export { PatdownOutput, PatdownOutputLive, type PatdownLintResult } from '#src/patdown-output';
3
+ export type { PatdownRuleSourceLayer } from '#src/patdown-rule-source-adapter';
4
+ export { runPatdownCli } from '#src/run-patdown-cli';
5
+ export { PatdownJudge, PatdownJudgeFailed, PatdownJudgmentSchema, askPatdownJudge, patdownJudgmentIsYes, patdownYesThreshold, type PatdownJudgment, } from '#src/patdown-judge';
6
+ export { defaultPatdownYesThreshold, decodePatdownYesThreshold, PatdownYesThresholdInvalid, type PatdownYesThreshold, } from '@patdown/rules';
7
+ export { TypeSafeJudgeLive } from '#src/typesafe-judge';
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAA;AAE7D,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,KAAK,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AAE9F,YAAY,EAAE,sBAAsB,EAAE,MAAM,kCAAkC,CAAA;AAE9E,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AAEpD,OAAO,EACN,YAAY,EACZ,kBAAkB,EAClB,qBAAqB,EACrB,eAAe,EACf,oBAAoB,EACpB,mBAAmB,EACnB,KAAK,eAAe,GACpB,MAAM,oBAAoB,CAAA;AAE3B,OAAO,EACN,0BAA0B,EAC1B,yBAAyB,EACzB,0BAA0B,EAC1B,KAAK,mBAAmB,GACxB,MAAM,gBAAgB,CAAA;AAEvB,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { patdownCommand, makePatdownCommand } from '#src/cli';
2
+ export { PatdownOutput, PatdownOutputLive } from '#src/patdown-output';
3
+ export { runPatdownCli } from '#src/run-patdown-cli';
4
+ export { PatdownJudge, PatdownJudgeFailed, PatdownJudgmentSchema, askPatdownJudge, patdownJudgmentIsYes, patdownYesThreshold, } from '#src/patdown-judge';
5
+ export { defaultPatdownYesThreshold, decodePatdownYesThreshold, PatdownYesThresholdInvalid, } from '@patdown/rules';
6
+ export { TypeSafeJudgeLive } from '#src/typesafe-judge';
@@ -0,0 +1,7 @@
1
+ import type { SquintRule } from '#/squint-rule';
2
+ /**
3
+ * Parse fuzzy squint rules from a markdown document. Text above the first `# heading` is ignored.
4
+ * Headings inside fenced code are ignored.
5
+ */
6
+ export declare function parseMarkdownSquintRules(markdown: string): ReadonlyArray<SquintRule>;
7
+ //# sourceMappingURL=markdown-squint-rule-parser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown-squint-rule-parser.d.ts","sourceRoot":"","sources":["../src/markdown-squint-rule-parser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAA;AAuG/C;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,aAAa,CAAC,UAAU,CAAC,CAiBpF"}
@@ -0,0 +1,85 @@
1
+ const atxHeadingPattern = /^#\s+(.*)$/u;
2
+ const globLinePattern = /^globs:\s*(.*)$/u;
3
+ function isFenceToggleLine(line) {
4
+ return line.startsWith('```');
5
+ }
6
+ function atxHeadingTitle(line) {
7
+ const match = atxHeadingPattern.exec(line);
8
+ if (match === null)
9
+ return undefined;
10
+ const title = match[1]?.trim() ?? '';
11
+ return title.length === 0 ? undefined : title;
12
+ }
13
+ function splitGlobList(raw) {
14
+ return raw.split(/[,\s]+/u).filter((part) => part.length > 0);
15
+ }
16
+ function globValuesFromLine(line) {
17
+ const match = globLinePattern.exec(line);
18
+ if (match === null)
19
+ return undefined;
20
+ return splitGlobList(match[1] ?? '');
21
+ }
22
+ function splitGlobsFromRuleBody(lines) {
23
+ let index = 0;
24
+ while (index < lines.length && lines[index]?.trim() === '') {
25
+ index += 1;
26
+ }
27
+ const globs = [];
28
+ while (index < lines.length) {
29
+ const globValues = globValuesFromLine(lines[index] ?? '');
30
+ if (globValues === undefined)
31
+ break;
32
+ globs.push(...globValues);
33
+ index += 1;
34
+ }
35
+ return {
36
+ squintRuleBody: lines.slice(index).join('\n').trim(),
37
+ squintRuleGlobs: globs,
38
+ };
39
+ }
40
+ function finishSquintRule(title, lines) {
41
+ const bodyParts = splitGlobsFromRuleBody(lines);
42
+ return {
43
+ squintRuleBody: bodyParts.squintRuleBody,
44
+ squintRuleGlobs: bodyParts.squintRuleGlobs,
45
+ squintRuleTitle: title,
46
+ };
47
+ }
48
+ function applyMarkdownLine(state, line) {
49
+ if (isFenceToggleLine(line)) {
50
+ state.inFence = !state.inFence;
51
+ if (state.currentTitle !== undefined)
52
+ state.currentLines.push(line);
53
+ return;
54
+ }
55
+ const headingTitle = state.inFence ? undefined : atxHeadingTitle(line);
56
+ if (headingTitle !== undefined) {
57
+ if (state.currentTitle !== undefined) {
58
+ state.rules.push(finishSquintRule(state.currentTitle, state.currentLines));
59
+ }
60
+ state.currentTitle = headingTitle;
61
+ state.currentLines = [];
62
+ return;
63
+ }
64
+ if (state.currentTitle !== undefined)
65
+ state.currentLines.push(line);
66
+ }
67
+ /**
68
+ * Parse fuzzy squint rules from a markdown document. Text above the first `# heading` is ignored.
69
+ * Headings inside fenced code are ignored.
70
+ */
71
+ export function parseMarkdownSquintRules(markdown) {
72
+ const state = {
73
+ currentLines: [],
74
+ currentTitle: undefined,
75
+ inFence: false,
76
+ rules: [],
77
+ };
78
+ for (const line of markdown.split(/\r?\n/u)) {
79
+ applyMarkdownLine(state, line);
80
+ }
81
+ if (state.currentTitle !== undefined) {
82
+ state.rules.push(finishSquintRule(state.currentTitle, state.currentLines));
83
+ }
84
+ return state.rules;
85
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=patdown-cli-bin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"patdown-cli-bin.d.ts","sourceRoot":"","sources":["../src/patdown-cli-bin.ts"],"names":[],"mappings":""}
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { NodeRuntime } from '@effect/platform-node';
3
+ import { runPatdownCli } from '#src/run-patdown-cli';
4
+ NodeRuntime.runMain(runPatdownCli());
@@ -0,0 +1,3 @@
1
+ /** Version printed by `patdown --version`. Comes from apps/patdown/package.json. */
2
+ export declare const patdownCliVersion: string;
3
+ //# sourceMappingURL=patdown-cli-version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"patdown-cli-version.d.ts","sourceRoot":"","sources":["../src/patdown-cli-version.ts"],"names":[],"mappings":"AAsBA,oFAAoF;AACpF,eAAO,MAAM,iBAAiB,QAAiC,CAAA"}
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { Schema } from 'effect';
5
+ const PatdownCliPackageJsonSchema = Schema.Struct({
6
+ version: Schema.NonEmptyString,
7
+ });
8
+ function readPatdownCliPackageVersion() {
9
+ const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
10
+ try {
11
+ return Schema.decodeUnknownSync(PatdownCliPackageJsonSchema)(JSON.parse(readFileSync(packageJsonPath, 'utf8'))).version;
12
+ }
13
+ catch {
14
+ throw new Error(`patdown: failed to read CLI version from ${packageJsonPath}`);
15
+ }
16
+ }
17
+ /** Version printed by `patdown --version`. Comes from apps/patdown/package.json. */
18
+ export const patdownCliVersion = readPatdownCliPackageVersion();
@@ -0,0 +1,30 @@
1
+ import { type PatdownYesThreshold } from '@patdown/rules';
2
+ import { Context, Effect, Schema } from 'effect';
3
+ /** Provider-neutral estimate. This is P(yes), not confidence in whichever answer wins. */
4
+ export declare const PatdownJudgmentSchema: Schema.Struct<{
5
+ readonly yesProbability: Schema.Finite;
6
+ }>;
7
+ /** A judge estimates the probability that the question is true of the supplied text. */
8
+ export type PatdownJudgment = typeof PatdownJudgmentSchema.Type;
9
+ declare const PatdownJudgeFailed_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
10
+ readonly _tag: "PatdownJudgeFailed";
11
+ } & Readonly<A>;
12
+ /** Provider or response validation failure, independent of the backend. */
13
+ export declare class PatdownJudgeFailed extends PatdownJudgeFailed_base<{
14
+ readonly message: string;
15
+ }> {
16
+ }
17
+ declare const PatdownJudge_base: Context.ServiceClass<PatdownJudge, "@patdown/cli/PatdownJudge", {
18
+ readonly ask: (question: string, inputText: string) => Effect.Effect<PatdownJudgment, PatdownJudgeFailed>;
19
+ }>;
20
+ /** Swappable judge service. Providers supply their own transport dependencies internally. */
21
+ export declare class PatdownJudge extends PatdownJudge_base {
22
+ }
23
+ /** Default cutoff, still used when no flag, package.json, or per-rule value is set. */
24
+ export declare const patdownYesThreshold = 0.85;
25
+ /** Applies a cutoff to a validated probability. Equality is not yes. */
26
+ export declare function patdownJudgmentIsYes(judgment: PatdownJudgment, yesThreshold?: PatdownYesThreshold): boolean;
27
+ /** Validates custom judge responses at the service boundary before applying policy or printing. */
28
+ export declare function askPatdownJudge(question: string, inputText: string): Effect.Effect<PatdownJudgment, PatdownJudgeFailed, PatdownJudge>;
29
+ export {};
30
+ //# sourceMappingURL=patdown-judge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"patdown-judge.d.ts","sourceRoot":"","sources":["../src/patdown-judge.ts"],"names":[],"mappings":"AAAA,OAAO,EAGN,KAAK,mBAAmB,EACxB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,OAAO,EAAQ,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAEtD,0FAA0F;AAC1F,eAAO,MAAM,qBAAqB;;EAEhC,CAAA;AAEF,wFAAwF;AACxF,MAAM,MAAM,eAAe,GAAG,OAAO,qBAAqB,CAAC,IAAI,CAAA;;;;AAE/D,2EAA2E;AAC3E,qBAAa,kBAAmB,SAAQ,wBAAuC;IAC9E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CACxB,CAAC;CAAG;;kBAMW,CACb,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,KACb,MAAM,CAAC,MAAM,CAAC,eAAe,EAAE,kBAAkB,CAAC;;AAPzD,6FAA6F;AAC7F,qBAAa,YAAa,SAAQ,iBAQF;CAAG;AAEnC,uFAAuF;AACvF,eAAO,MAAM,mBAAmB,OAA6B,CAAA;AAE7D,wEAAwE;AACxE,wBAAgB,oBAAoB,CACnC,QAAQ,EAAE,eAAe,EACzB,YAAY,GAAE,mBAAgD,GAC5D,OAAO,CAET;AAED,mGAAmG;AACnG,wBAAgB,eAAe,CAC9B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GACf,MAAM,CAAC,MAAM,CAAC,eAAe,EAAE,kBAAkB,EAAE,YAAY,CAAC,CAYlE"}
@@ -0,0 +1,26 @@
1
+ import { defaultPatdownYesThreshold, patdownJudgmentIsYes as comparePatdownYesProbability, } from '@patdown/rules';
2
+ import { Context, Data, Effect, Schema } from 'effect';
3
+ /** Provider-neutral estimate. This is P(yes), not confidence in whichever answer wins. */
4
+ export const PatdownJudgmentSchema = Schema.Struct({
5
+ yesProbability: Schema.Finite.check(Schema.isBetween({ minimum: 0, maximum: 1 })),
6
+ });
7
+ /** Provider or response validation failure, independent of the backend. */
8
+ export class PatdownJudgeFailed extends Data.TaggedError('PatdownJudgeFailed') {
9
+ }
10
+ /** Swappable judge service. Providers supply their own transport dependencies internally. */
11
+ export class PatdownJudge extends Context.Service()('@patdown/cli/PatdownJudge') {
12
+ }
13
+ /** Default cutoff, still used when no flag, package.json, or per-rule value is set. */
14
+ export const patdownYesThreshold = defaultPatdownYesThreshold;
15
+ /** Applies a cutoff to a validated probability. Equality is not yes. */
16
+ export function patdownJudgmentIsYes(judgment, yesThreshold = defaultPatdownYesThreshold) {
17
+ return comparePatdownYesProbability(judgment.yesProbability, yesThreshold);
18
+ }
19
+ /** Validates custom judge responses at the service boundary before applying policy or printing. */
20
+ export function askPatdownJudge(question, inputText) {
21
+ return Effect.gen(function* () {
22
+ const judge = yield* PatdownJudge;
23
+ const answer = yield* judge.ask(question, inputText);
24
+ return yield* Schema.decodeEffect(PatdownJudgmentSchema)(answer).pipe(Effect.mapError(() => new PatdownJudgeFailed({ message: 'patdown: judge returned an invalid yes probability' })));
25
+ });
26
+ }
@@ -0,0 +1,7 @@
1
+ import { PatdownYesThresholdInvalid, type PatdownRulesDocument, type PatdownYesThreshold } from '@patdown/rules';
2
+ import { Effect, FileSystem, Path } from 'effect';
3
+ import { PatdownJudge, PatdownJudgeFailed } from '#src/patdown-judge';
4
+ import { PatdownOutput } from '#src/patdown-output';
5
+ /** Lint files matched by each rule's globs. A yes judgment means a violation. */
6
+ export declare function runPatdownLint(document: PatdownRulesDocument, verbose?: boolean, yesThreshold?: PatdownYesThreshold): Effect.Effect<boolean, PatdownJudgeFailed | PatdownYesThresholdInvalid, FileSystem.FileSystem | PatdownJudge | Path.Path | PatdownOutput>;
7
+ //# sourceMappingURL=patdown-lint.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"patdown-lint.d.ts","sourceRoot":"","sources":["../src/patdown-lint.ts"],"names":[],"mappings":"AAAA,OAAO,EAEN,0BAA0B,EAE1B,KAAK,oBAAoB,EACzB,KAAK,mBAAmB,EACxB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA;AAEjD,OAAO,EACN,YAAY,EACZ,kBAAkB,EAGlB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAA;AA0InD,iFAAiF;AACjF,wBAAgB,cAAc,CAC7B,QAAQ,EAAE,oBAAoB,EAC9B,OAAO,GAAE,OAAe,EACxB,YAAY,GAAE,mBAAgD,GAC5D,MAAM,CAAC,MAAM,CACf,OAAO,EACP,kBAAkB,GAAG,0BAA0B,EAC/C,UAAU,CAAC,UAAU,GAAG,YAAY,GAAG,IAAI,CAAC,IAAI,GAAG,aAAa,CAChE,CAaA"}
@@ -0,0 +1,89 @@
1
+ import { defaultPatdownYesThreshold, PatdownYesThresholdInvalid, } from '@patdown/rules';
2
+ import { Effect, FileSystem, Path } from 'effect';
3
+ import { PatdownJudge, PatdownJudgeFailed, askPatdownJudge, patdownJudgmentIsYes, } from '#src/patdown-judge';
4
+ import { PatdownOutput } from '#src/patdown-output';
5
+ import { decodePatdownRuleYesThreshold } from '#src/patdown-yes-threshold-config';
6
+ const patdownGlobExcludes = [
7
+ '**/.git/**',
8
+ '**/.turbo/**',
9
+ '**/coverage/**',
10
+ '**/dist/**',
11
+ '**/node_modules/**',
12
+ ];
13
+ function patdownGlobPatterns(globs) {
14
+ return globs.length === 0 ? ['**/*'] : globs;
15
+ }
16
+ function patdownViolationInstructions(rule) {
17
+ return [
18
+ 'Does this file violate the following patdown rule? Answer yes only if there is a clear violation.',
19
+ '',
20
+ `# ${rule.patdownRuleTitle}`,
21
+ '',
22
+ rule.patdownRuleBody,
23
+ ].join('\n');
24
+ }
25
+ function patdownFileState(relativePath, contents) {
26
+ return `path: ${relativePath}\n\n${contents}`;
27
+ }
28
+ function globPatdownRuleFiles(cwd, globs) {
29
+ return Effect.gen(function* () {
30
+ const fileSystem = yield* FileSystem.FileSystem;
31
+ const patterns = patdownGlobPatterns(globs);
32
+ const matches = [];
33
+ for (const pattern of patterns) {
34
+ const found = yield* fileSystem
35
+ .glob(pattern, {
36
+ exclude: patdownGlobExcludes,
37
+ root: cwd,
38
+ })
39
+ .pipe(Effect.orElseSucceed(() => []));
40
+ matches.push(...found);
41
+ }
42
+ return [...new Set(matches)].toSorted();
43
+ });
44
+ }
45
+ function lintPatdownRuleFile(rule, filePath, options) {
46
+ return Effect.gen(function* () {
47
+ const fileSystem = yield* FileSystem.FileSystem;
48
+ const path = yield* Path.Path;
49
+ const output = yield* PatdownOutput;
50
+ const relativePath = path.relative(options.cwd, filePath);
51
+ const contents = yield* fileSystem.readFileString(filePath).pipe(Effect.mapError(() => new PatdownJudgeFailed({
52
+ message: `patdown: failed to read ${relativePath}`,
53
+ })));
54
+ const answer = yield* askPatdownJudge(patdownViolationInstructions(rule), patdownFileState(relativePath, contents));
55
+ const failed = patdownJudgmentIsYes(answer, options.yesThreshold);
56
+ yield* output.writeLintResult({
57
+ violated: failed,
58
+ ruleTitle: rule.patdownRuleTitle,
59
+ filePath: relativePath,
60
+ violationProbability: answer.yesProbability,
61
+ yesThreshold: options.yesThreshold,
62
+ }, options.verbose);
63
+ return failed;
64
+ });
65
+ }
66
+ function lintPatdownRule(rule, cwd, verbose, defaultYesThreshold) {
67
+ return Effect.gen(function* () {
68
+ const output = yield* PatdownOutput;
69
+ const files = yield* globPatdownRuleFiles(cwd, rule.patdownRuleGlobs);
70
+ const yesThreshold = rule.patdownRuleYesThreshold === undefined
71
+ ? defaultYesThreshold
72
+ : yield* decodePatdownRuleYesThreshold(rule.patdownRuleYesThreshold, rule.patdownRuleTitle);
73
+ if (files.length === 0) {
74
+ yield* output.writeNoFilesMatched(rule.patdownRuleTitle);
75
+ return false;
76
+ }
77
+ const failures = yield* Effect.forEach(files, (filePath) => lintPatdownRuleFile(rule, filePath, { cwd, verbose, yesThreshold }), { concurrency: 1 });
78
+ return failures.some((failed) => failed);
79
+ });
80
+ }
81
+ /** Lint files matched by each rule's globs. A yes judgment means a violation. */
82
+ export function runPatdownLint(document, verbose = false, yesThreshold = defaultPatdownYesThreshold) {
83
+ return Effect.gen(function* () {
84
+ const path = yield* Path.Path;
85
+ const cwd = path.resolve('.');
86
+ const failures = yield* Effect.forEach(document.patdownRules, (rule) => lintPatdownRule(rule, cwd, verbose, yesThreshold), { concurrency: 1 });
87
+ return failures.some((failed) => failed);
88
+ });
89
+ }