@kensio/skills 1.13.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/README.md +48 -0
- package/bin/kensio-skills.mjs +213 -0
- package/package.json +39 -0
- package/skills/dynamodb-single-table/SKILL.md +329 -0
- package/skills/dynamodb-single-table/references/aws-guidance.md +183 -0
- package/skills/github-issue-drafting/SKILL.md +260 -0
- package/skills/isolated-testing-style/SKILL.md +293 -0
- package/skills/pangram-check/SKILL.md +125 -0
- package/skills/pangram-check/references/configuration.md +120 -0
- package/skills/pangram-check/references/reading-results.md +69 -0
- package/skills/pangram-check/scripts/pangram-check.mjs +807 -0
- package/skills/part-factory-test-data/SKILL.md +238 -0
- package/skills/skill-template/SKILL.md +126 -0
- package/skills/technical-prose-style/SKILL.md +356 -0
- package/skills/technical-prose-style/references/measurements.md +356 -0
- package/skills/technical-prose-style/scripts/prose-check.mjs +381 -0
- package/skills/yulin-aws-simulation/SKILL.md +324 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: isolated-testing-style
|
|
3
|
+
description: Write tests that start from given/when/then, use real collaborators through simulation rather than stubs and mocks, take their isolation from randomised data rather than shared setup and teardown, and assert behaviour rather than call counts. Use when writing or reviewing tests, when a test needs a collaborator faked, when reaching for a mock, spy, `toHaveBeenCalledWith`, `beforeEach`/`afterEach` fixtures or a hardcoded expected hash, when test setup has grown tangled, and when asked "how should I test this?".
|
|
4
|
+
license: Apache-2.0
|
|
5
|
+
metadata:
|
|
6
|
+
version: "1.13.1"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Isolated testing style
|
|
10
|
+
|
|
11
|
+
An opinionated way of writing tests. The examples are TypeScript and vitest, but the rules are about
|
|
12
|
+
test design and hold for any framework. Each rule exists because of a specific failure it would have
|
|
13
|
+
caught.
|
|
14
|
+
|
|
15
|
+
## Start with Given, When, Then
|
|
16
|
+
|
|
17
|
+
Write the three comments before the test body, and before the code they will drive.
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
it("refuses an order once the offer has closed", async () => {
|
|
21
|
+
// Given an offer that closed while the customer was on the page.
|
|
22
|
+
// When the order is placed against it.
|
|
23
|
+
// Then it is refused rather than accepted late.
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Starting at this point, and not drifting to it, does three things.
|
|
28
|
+
|
|
29
|
+
**It lowers the cost of starting.** You only have to say what the situation is, what happens, and
|
|
30
|
+
what should result. That is a smaller question than "how do I test this?", and you can usually
|
|
31
|
+
answer it before you can answer the bigger one.
|
|
32
|
+
|
|
33
|
+
**It designs the interface.** Filling in `// When` forces you to name the single action under test,
|
|
34
|
+
in the caller's vocabulary. A step you cannot write as one `// When` usually means the interface is
|
|
35
|
+
wrong.
|
|
36
|
+
|
|
37
|
+
**It keeps the test readable as documentation.** Tests are read far more often than they are
|
|
38
|
+
written, and without the structure it is easy to produce a body where essential behaviour and
|
|
39
|
+
incidental setup look alike.
|
|
40
|
+
|
|
41
|
+
Then fill each comment in with the case it covers, and never with a restatement of the code:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
it("refuses an order once the offer has closed", async () => {
|
|
45
|
+
// Given an offer that closed while the customer was on the page.
|
|
46
|
+
const offer = await offerFactory.make({ closesAt: aMinuteAgo });
|
|
47
|
+
|
|
48
|
+
// When the order is placed against it.
|
|
49
|
+
const placing = placeOrder(orderFactory.make({ offerId: offer.id }));
|
|
50
|
+
|
|
51
|
+
// Then it is refused rather than accepted late.
|
|
52
|
+
await expect(placing).rejects.toThrow(OfferClosedError);
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`// Given an offer` restates the code and adds no information.
|
|
57
|
+
`// Given an offer that closed while the customer was on the page` says which case this is and why
|
|
58
|
+
it matters. This holds whether or not the test is written first.
|
|
59
|
+
|
|
60
|
+
## Prefer real collaborators through simulation
|
|
61
|
+
|
|
62
|
+
A stub asserts that your code called something. A simulator asserts that it called the service
|
|
63
|
+
correctly.
|
|
64
|
+
|
|
65
|
+
That difference is the whole argument. A stub answers whatever you told it to answer. It agrees with
|
|
66
|
+
your understanding of the API by construction. It cannot disagree with you, which means it cannot
|
|
67
|
+
find the case where your understanding is wrong. A simulator holds real state and applies the real
|
|
68
|
+
rules. A wrong call fails at the point the real service would have failed.
|
|
69
|
+
|
|
70
|
+
The evidence comes from a real project. Replacing AWS SDK stubs with a simulator immediately caught
|
|
71
|
+
two bugs that had already shipped.
|
|
72
|
+
|
|
73
|
+
- A Secrets Manager secret name ending in a hyphen and six characters. AWS appends exactly that
|
|
74
|
+
suffix to a secret ARN. The name was ambiguous with the ARN form, which AWS advises against. The
|
|
75
|
+
stub had no opinion, because a stub has no naming rules.
|
|
76
|
+
- A Cognito `SECRET_HASH` computed the wrong way. The stub accepted it, because the stub was never
|
|
77
|
+
going to check a signature.
|
|
78
|
+
|
|
79
|
+
So the order to reach for things:
|
|
80
|
+
|
|
81
|
+
1. A simulator that holds real state and applies real rules.
|
|
82
|
+
2. The real thing, when it runs in process and needs no external service.
|
|
83
|
+
3. A stub, only for something with no rules worth modelling, such as a clock or a random source.
|
|
84
|
+
|
|
85
|
+
The first two options need the implementation to be swappable, so give each collaborator a single
|
|
86
|
+
point of entry, one place that wires the real service in production and a simulation in tests. The
|
|
87
|
+
driver pattern is one way to arrange that, but the name matters far less than the swap having one
|
|
88
|
+
home.
|
|
89
|
+
|
|
90
|
+
Simulating in process pays off beyond avoiding stubs. No deployment is needed before running the
|
|
91
|
+
tests, a debugger steps through the collaborator's state alongside your own, and no state is shared
|
|
92
|
+
between processes, so several layers can be exercised together and still run in parallel at the
|
|
93
|
+
speed of a unit test.
|
|
94
|
+
|
|
95
|
+
Be honest about the limit. An in-memory implementation only approximates the real service, and
|
|
96
|
+
cannot be relied on to behave identically. Keep a thin layer of tests against the real thing for the
|
|
97
|
+
flows where that matters, and treat any divergence you find as a bug in the simulation rather than a
|
|
98
|
+
quirk to work around.
|
|
99
|
+
|
|
100
|
+
## Keep setup cheap and independent
|
|
101
|
+
|
|
102
|
+
Tangled shared fixtures come from economics. Discipline has little to do with it. Teams share setup
|
|
103
|
+
roughly in proportion to how expensive it is to build. When getting a test into the right state
|
|
104
|
+
means threading through a web of existing fixtures, reusing what is already there is the rational
|
|
105
|
+
move, and each reuse adds another edge to the graph. That is how a suite arrives at setup that no
|
|
106
|
+
one dares to touch.
|
|
107
|
+
|
|
108
|
+
The fix is to share differently. Shared factories for test entities are exactly what you want. A
|
|
109
|
+
factory that constructs a type is worth writing once and using everywhere. What has to be avoided is
|
|
110
|
+
those factories getting tangled up with each other. Each piece of setup should stand on its own.
|
|
111
|
+
|
|
112
|
+
Independence comes from taking dependencies explicitly rather than reaching for ambient state. A
|
|
113
|
+
factory that is handed what it needs stays pure, and the test decides what to hand it.
|
|
114
|
+
`@kensio/part-factory` builds this in. Factories take a `dependencies` object as a second argument
|
|
115
|
+
at call time. A factory that needs a simulated AWS is handed one, and never goes looking.
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
// Given an order that exists in this test's own simulated AWS.
|
|
119
|
+
const simAws = new SimAws();
|
|
120
|
+
const order = await orderFactory.make({ total: 5000 }, { simAws });
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A factory built that way can be shared as widely as you like and still stand on its own, because
|
|
124
|
+
everything it does is independent of what another factory did first. Prefer collaborators and
|
|
125
|
+
factories that need only instantiation, with no side effects, no cleanup and no coordination.
|
|
126
|
+
|
|
127
|
+
For a step specific to one test, ask whether the step belongs to the test or the test belongs to the
|
|
128
|
+
step. A helper confined to one file can be pulled back inline later if it stops earning its place,
|
|
129
|
+
whereas a fixture that a large part of the suite is built on cannot. Reversibility is the thing to
|
|
130
|
+
preserve, and locality on its own earns little.
|
|
131
|
+
|
|
132
|
+
## Take isolation from randomised data
|
|
133
|
+
|
|
134
|
+
Randomised values make collisions impossible. No teardown is required, and there is no ordering to
|
|
135
|
+
depend on. Randomised is enough. Guaranteed uniqueness is unnecessary, and a UUID has no realistic
|
|
136
|
+
chance of colliding anyway.
|
|
137
|
+
|
|
138
|
+
Do not do this:
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
// Anti-pattern: shared name, mutable handle, teardown to undo it.
|
|
142
|
+
let bucketName: string;
|
|
143
|
+
|
|
144
|
+
beforeEach(async () => {
|
|
145
|
+
bucketName = "uploads-bucket";
|
|
146
|
+
await createBucket(bucketName);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
afterEach(async () => {
|
|
150
|
+
await emptyBucket(bucketName);
|
|
151
|
+
await deleteBucket(bucketName);
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
That test cannot run beside another test using the same name, the `let` is only mutable so that
|
|
156
|
+
`afterEach` can reach it, and a failure part way through leaves the next test to fail for a reason
|
|
157
|
+
unrelated to it.
|
|
158
|
+
|
|
159
|
+
Do this instead:
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
it("serves an uploaded object", async () => {
|
|
163
|
+
// Given a bucket no other test can be talking about.
|
|
164
|
+
const bucketName = `uploads-${faker.string.uuid()}`;
|
|
165
|
+
await createBucket(bucketName);
|
|
166
|
+
|
|
167
|
+
// ...
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The environment those tests run against can be shared, and should be built the way production is
|
|
172
|
+
built. A realistic environment that several tests read from is closer to production than a minimal
|
|
173
|
+
one rebuilt per test, and it is faster. The isolation comes from the names and identifiers, so
|
|
174
|
+
sharing the environment is free.
|
|
175
|
+
|
|
176
|
+
Faker is the source of these values. Prefer a generator that produces a realistic value of the right
|
|
177
|
+
kind (`faker.internet.email()`, `faker.string.uuid()`, `faker.company.name()`) over a counter or a
|
|
178
|
+
literal with a suffix. The test data also exercises the shapes production data has.
|
|
179
|
+
|
|
180
|
+
## Assert observable behaviour
|
|
181
|
+
|
|
182
|
+
A call count asserts how the code is written today. A behaviour assertion holds however it is
|
|
183
|
+
written, which is what lets you refactor.
|
|
184
|
+
|
|
185
|
+
To prove a value is cached, do not count calls. Delete the underlying resource, then show the cached
|
|
186
|
+
value still comes back:
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
it("keeps serving the secret after it is deleted", async () => {
|
|
190
|
+
// Given a secret that has been read once, so it is cached.
|
|
191
|
+
const first = await config.databasePassword();
|
|
192
|
+
|
|
193
|
+
// When the underlying secret goes away.
|
|
194
|
+
await simAws.secretsManager().deleteSecret(
|
|
195
|
+
new DeleteSecretCommand({ SecretId: secretName }),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Then the cached value is still served, without going back to the service.
|
|
199
|
+
expect(await config.databasePassword()).toEqual(first);
|
|
200
|
+
});
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
To prove a retry, make the first call fail and the second succeed, then assert on the result:
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
// Given an endpoint that fails once and then works.
|
|
207
|
+
// When the client calls it.
|
|
208
|
+
// Then it gets the successful response.
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Both hold whether the cache is a `Map`, a memoised promise or a decorator, and whether the retry is
|
|
212
|
+
a loop, a middleware or a library.
|
|
213
|
+
|
|
214
|
+
## Never pin a value computed by the code under test
|
|
215
|
+
|
|
216
|
+
Pinning a hash you generated by running the same function only proves the function is deterministic.
|
|
217
|
+
It will keep passing after the function becomes wrong, as long as it is wrong consistently.
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
// Anti-pattern: this string came from running computeSecretHash.
|
|
221
|
+
expect(computeSecretHash(username, clientId, clientSecret)).toBe(
|
|
222
|
+
"z0Xq9k1e4mVQ...",
|
|
223
|
+
);
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Two ways out, in order of preference:
|
|
227
|
+
|
|
228
|
+
1. Let a real implementation validate it. A simulated Cognito checks a `SECRET_HASH` the way Cognito
|
|
229
|
+
checks it. A sign-in that succeeds against the simulation is evidence the hash is right.
|
|
230
|
+
2. Pin against an independent authority, such as a value from the service's own documentation, a
|
|
231
|
+
published test vector, or a value produced by a different implementation.
|
|
232
|
+
|
|
233
|
+
The same rule covers snapshot tests of anything the code under test formats. A snapshot records what
|
|
234
|
+
the code does, and stays silent on what it should do.
|
|
235
|
+
|
|
236
|
+
## Keep the top level to imports
|
|
237
|
+
|
|
238
|
+
In an ideal vitest or jest file, the only things outside the top-level `describe()` are the imports.
|
|
239
|
+
State, construction and helpers all live inside it. The file reads as a description of behaviour and
|
|
240
|
+
not as a program that happens to contain some tests.
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
import { describe, expect, it } from "vitest";
|
|
244
|
+
import { faker } from "@faker-js/faker";
|
|
245
|
+
|
|
246
|
+
import { placeOrder } from "./place-order";
|
|
247
|
+
import { offerFactory } from "./order.test-support";
|
|
248
|
+
|
|
249
|
+
describe("placing an order", () => {
|
|
250
|
+
// Everything else — state, helpers, tests — lives in here.
|
|
251
|
+
});
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
This is mostly a consequence of the other rules and adds little on its own. What usually accumulates
|
|
255
|
+
at the top level of a test file is module-level state the tests share, a mutable handle that exists
|
|
256
|
+
so `afterEach` can reach it, and hoisted mock registrations. The rules above have already turned
|
|
257
|
+
down all three. So a top level that will not stay empty is a useful signal that something further up
|
|
258
|
+
has slipped. Imported factories are fine here. They arrive as imports precisely because they stand
|
|
259
|
+
on their own.
|
|
260
|
+
|
|
261
|
+
## Put test support beside the code
|
|
262
|
+
|
|
263
|
+
A test file should hold tests. Where support lives depends on what it is for.
|
|
264
|
+
|
|
265
|
+
A factory for a **type** belongs beside the type it constructs, exported. That no consumer ever
|
|
266
|
+
hand-rolls the literal:
|
|
267
|
+
|
|
268
|
+
```
|
|
269
|
+
src/orders/
|
|
270
|
+
├── order.ts
|
|
271
|
+
├── order.test.ts
|
|
272
|
+
└── order.test-support.ts # factories and helpers for order.ts
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
A library that defines a shape other code has to construct should export the factory for it.
|
|
276
|
+
|
|
277
|
+
A step written for **one test** stays in that test's file, inside the `describe`. Promoting it later
|
|
278
|
+
when another test wants it is fine. Make it independent first, so what spreads is a self-contained
|
|
279
|
+
factory (not a dependency on how some other test left things).
|
|
280
|
+
|
|
281
|
+
If a test file is mostly setup, that is a signal. The fix is usually cheaper construction. A shared
|
|
282
|
+
fixture treats the symptom. Scroll the file and see how much of it is `it(...)` bodies making
|
|
283
|
+
assertions.
|
|
284
|
+
|
|
285
|
+
## Tools that help
|
|
286
|
+
|
|
287
|
+
These serve the style. The style holds without them.
|
|
288
|
+
|
|
289
|
+
- [Faker](https://fakerjs.dev/) for randomised, realistic values.
|
|
290
|
+
- [`@kensio/part-factory`](https://partfactory.dev/) for typed factories that need only
|
|
291
|
+
instantiation. See the `part-factory-test-data` skill.
|
|
292
|
+
- [`@kensio/yulin`](https://yulinsim.dev/) simulates AWS in process, when AWS is the collaborator.
|
|
293
|
+
See the `yulin-aws-simulation` skill.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pangram-check
|
|
3
|
+
description: Send the prose of a finished document to Pangram, a commercial AI-text detector, and report which passages read as machine-drafted, with the source line each one starts on. Use when the user asks to "check this with Pangram", "run the AI detector over this", "see if this still reads as AI", or wants a final sweep after rewriting a draft. Every run is paid and sends the writing to a third party, so this skill is invoked by name.
|
|
4
|
+
license: Apache-2.0
|
|
5
|
+
compatibility: Needs a Pangram API key and network access. Every run is billed.
|
|
6
|
+
metadata:
|
|
7
|
+
version: "1.13.1"
|
|
8
|
+
disable-model-invocation: true
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Pangram check
|
|
12
|
+
|
|
13
|
+
A final sweep over a finished document. Pangram scores the text in windows of a few hundred words
|
|
14
|
+
and reports which parts read as machine-drafted, so a long document gets a passage-by-passage
|
|
15
|
+
answer.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
node scripts/pangram-check.mjs path/to/file.md
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Full flag list and config file in [references/configuration.md](references/configuration.md). Field
|
|
22
|
+
meanings and how to read a verdict in
|
|
23
|
+
[references/reading-results.md](references/reading-results.md).
|
|
24
|
+
|
|
25
|
+
## Confirm before spending a call
|
|
26
|
+
|
|
27
|
+
Every run costs the user money and sends their writing to a third party. Before the first call of a
|
|
28
|
+
session:
|
|
29
|
+
|
|
30
|
+
1. Name the file that is about to go out.
|
|
31
|
+
2. Give the word count and the billable estimate. `--dry-run` prints both and sends nothing.
|
|
32
|
+
3. Wait for a yes.
|
|
33
|
+
|
|
34
|
+
This skill sets `disable-model-invocation: true`. It runs when a user asks for it by name, and never
|
|
35
|
+
as a step some other task decided to take. A skill that drafts or rewrites text may mention it and
|
|
36
|
+
stop there.
|
|
37
|
+
|
|
38
|
+
## Check the key first
|
|
39
|
+
|
|
40
|
+
On a machine that has not run this before:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
node scripts/pangram-check.mjs --check-key
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
That checks the key against `GET /models` and spends no detection call. With no key found, the
|
|
47
|
+
script prints where it looked and how to store one. Pass that message on as it stands. Never print,
|
|
48
|
+
echo or paste the key itself, and never put it in a shell command the user will see.
|
|
49
|
+
|
|
50
|
+
The key is read from `$PANGRAM_API_KEY`, then `$PANGRAM_ENV_FILE`, then `~/.config/pangram/.env`,
|
|
51
|
+
then `./.env`. Recommend the third one, which every repository on the machine can reach.
|
|
52
|
+
|
|
53
|
+
## Only the prose is sent
|
|
54
|
+
|
|
55
|
+
The document is reduced to its prose before the call, and that reduced text is what Pangram
|
|
56
|
+
receives. Frontmatter, code blocks, HTML comments, images, shortcodes, raw HTML, tables, headings
|
|
57
|
+
and URLs are all removed. Link text and inline code keep their words.
|
|
58
|
+
|
|
59
|
+
Check what would go out with `--print-prose`. The word count the script reports is that same string,
|
|
60
|
+
so a document reporting `512 words of prose` sent exactly those 512 words whatever the file length.
|
|
61
|
+
|
|
62
|
+
This matters for reading the verdict. A page that is mostly code would otherwise have its bash and
|
|
63
|
+
JSON scored alongside the writing, which pulls the fractions towards human for reasons unrelated to
|
|
64
|
+
how the prose reads.
|
|
65
|
+
|
|
66
|
+
Wrap quoted material in `<!-- pangram-check:off -->` and `<!-- pangram-check:on -->` to keep someone
|
|
67
|
+
else's writing out of the score.
|
|
68
|
+
|
|
69
|
+
## The guards
|
|
70
|
+
|
|
71
|
+
Two of them refuse to send anything.
|
|
72
|
+
|
|
73
|
+
- **Under 50 words of prose:** Pangram's own documented floor, below which it declines to predict.
|
|
74
|
+
- **Under `--min-words`** (300 by default). Pangram predicts from 50 words up with less confidence
|
|
75
|
+
the shorter the text. Lower the floor for a short document, and treat the verdict as weaker.
|
|
76
|
+
|
|
77
|
+
A repository can add its own with `rejectPatterns` in `.pangram-check.json`, or `--reject <regex>`
|
|
78
|
+
for one run. `--reject-todo` is the preset for the common case, where a `TODO` marker means a
|
|
79
|
+
paragraph is still to be written and scoring it would spend a paid call on a draft.
|
|
80
|
+
|
|
81
|
+
`--max-units <n>` refuses a document over a cost ceiling. One billable unit is each started block of
|
|
82
|
+
1000 words.
|
|
83
|
+
|
|
84
|
+
## Repeat runs are free
|
|
85
|
+
|
|
86
|
+
Results are cached by a hash of the extracted prose, so running the same unchanged document again
|
|
87
|
+
reads the cache and calls nothing. Edit the prose and the next run is a fresh call. `--refresh`
|
|
88
|
+
forces one, `--no-cache` skips the cache in both directions.
|
|
89
|
+
|
|
90
|
+
There is deliberately no score history and no before-and-after delta. Both would invite treating the
|
|
91
|
+
number as a target.
|
|
92
|
+
|
|
93
|
+
## Output
|
|
94
|
+
|
|
95
|
+
`--format text` (the default) colour-codes scores on a terminal and draws the windows in reading
|
|
96
|
+
order. `--format markdown` gives a table to paste into a report. `--format json` carries every field
|
|
97
|
+
plus the source line for each window.
|
|
98
|
+
|
|
99
|
+
## Reporting back
|
|
100
|
+
|
|
101
|
+
Give the user the headline, the three fractions, and the worst two or three windows with their
|
|
102
|
+
scores and line references. Point at the passages by line so they can open them.
|
|
103
|
+
|
|
104
|
+
Leave the judgement about rewriting to the user. A high score marks a passage worth rereading, and
|
|
105
|
+
it is not a number to drive down. Editing to move a detector score is a different activity from
|
|
106
|
+
writing in your own voice, and the two come apart quickly.
|
|
107
|
+
|
|
108
|
+
## Limits
|
|
109
|
+
|
|
110
|
+
Pangram reports whether text reads as machine-generated. It has no opinion on whether the writing is
|
|
111
|
+
any good, and a document can come back fully human while being badly organised.
|
|
112
|
+
|
|
113
|
+
A `is_humanized` flag on a window is a stronger signal than a high score by itself, because it means
|
|
114
|
+
the passage looks like text that has been worked over to read as human.
|
|
115
|
+
|
|
116
|
+
Detectors carry false positives, which fall hardest on writers working in a second language. Treat
|
|
117
|
+
one verdict as one input to a rereading, never as proof of authorship, and say so if a user starts
|
|
118
|
+
treating it as proof.
|
|
119
|
+
|
|
120
|
+
## Related skills
|
|
121
|
+
|
|
122
|
+
`technical-prose-style` covers a different question. It measures the constructions that make prose
|
|
123
|
+
tiring to read, and its own measurements show that text following every one of its rules is still
|
|
124
|
+
identified as machine-written. Style and provenance are separate signals. Anyone after concealment
|
|
125
|
+
is holding the wrong tool.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
Every setting has a flag. The ones a repository wants every time belong in a config file.
|
|
4
|
+
|
|
5
|
+
## Flags
|
|
6
|
+
|
|
7
|
+
| Flag | Effect |
|
|
8
|
+
| ------------------ | ---------------------------------------------------------------- |
|
|
9
|
+
| `--dry-run` | Runs the guards, prints the cost estimate, sends nothing |
|
|
10
|
+
| `--print-prose` | Prints the extracted prose and exits |
|
|
11
|
+
| `--format <f>` | `text` (default), `markdown` or `json` |
|
|
12
|
+
| `--windows <n>` | How many windows to detail, or `all` (default 5) |
|
|
13
|
+
| `--min-words <n>` | Refuses below this many words of prose (default 300) |
|
|
14
|
+
| `--max-units <n>` | Refuses over this many billable units |
|
|
15
|
+
| `--reject-todo` | Refuses while the file holds `TODO` markers |
|
|
16
|
+
| `--reject <regex>` | Refuses when the pattern matches the file. Repeatable |
|
|
17
|
+
| `--skip-quotes` | Leaves blockquoted material out of the prose |
|
|
18
|
+
| `--plain` | Treats the file as plain text, skipping the markdown rules |
|
|
19
|
+
| `--model <name>` | Pangram model selector, passed through as `model` |
|
|
20
|
+
| `--list-models` | Lists the selectors the key allows, then exits |
|
|
21
|
+
| `--check-key` | Checks the key is accepted, then exits. Spends no detection call |
|
|
22
|
+
| `--refresh` | Ignores any cached result for this text |
|
|
23
|
+
| `--no-cache` | Neither reads nor writes the cache |
|
|
24
|
+
| `--config <path>` | Uses this config file |
|
|
25
|
+
| `--no-config` | Ignores any `.pangram-check.json` |
|
|
26
|
+
| `--no-color` | Plain output on a terminal |
|
|
27
|
+
|
|
28
|
+
Exit codes: `0` ran, `1` a guard refused or Pangram failed, `2` a usage, config or key problem.
|
|
29
|
+
|
|
30
|
+
## The config file
|
|
31
|
+
|
|
32
|
+
`.pangram-check.json`, found by walking up from the file being checked. The first one found wins,
|
|
33
|
+
and flags override it. An unknown key prints a warning and is ignored.
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"minWords": 300,
|
|
38
|
+
"maxUnits": 2,
|
|
39
|
+
"rejectTodo": true,
|
|
40
|
+
"rejectPatterns": ["<!--\\s*FIXME", "^\\s*XXX\\b"],
|
|
41
|
+
"skipQuotes": false,
|
|
42
|
+
"format": "markdown",
|
|
43
|
+
"windows": 3,
|
|
44
|
+
"model": "default",
|
|
45
|
+
"cache": true
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Patterns are JavaScript regular expressions, matched against the raw file with the `g` and `m`
|
|
50
|
+
flags, so `^` and `$` anchor to a line. Backslashes need escaping for JSON. Every match is reported
|
|
51
|
+
with its line number.
|
|
52
|
+
|
|
53
|
+
`rejectTodo` is the preset behind `--reject-todo`, and matches a `TODO` opening an HTML comment or a
|
|
54
|
+
line.
|
|
55
|
+
|
|
56
|
+
## The API key
|
|
57
|
+
|
|
58
|
+
Read from the first of these that carries `PANGRAM_API_KEY`, and never printed:
|
|
59
|
+
|
|
60
|
+
1. `$PANGRAM_API_KEY` in the environment
|
|
61
|
+
2. `$PANGRAM_ENV_FILE`, a path to a dotenv file
|
|
62
|
+
3. `~/.config/pangram/.env` (or `$XDG_CONFIG_HOME/pangram/.env`)
|
|
63
|
+
4. `./.env` in the working directory
|
|
64
|
+
|
|
65
|
+
The third is the one to recommend. It sits outside any repository and every project on the machine
|
|
66
|
+
can reach it.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
mkdir -p ~/.config/pangram
|
|
70
|
+
printf 'PANGRAM_API_KEY=%s\n' 'the-key' > ~/.config/pangram/.env
|
|
71
|
+
chmod 600 ~/.config/pangram/.env
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A placeholder value (anything starting `replace`, `your` or `<`) counts as absent. The key is
|
|
75
|
+
redacted from any error text the script prints.
|
|
76
|
+
|
|
77
|
+
`$PANGRAM_API_BASE` overrides the API host. The test stub in this repository uses it.
|
|
78
|
+
|
|
79
|
+
## What counts as prose
|
|
80
|
+
|
|
81
|
+
These are removed entirely:
|
|
82
|
+
|
|
83
|
+
- YAML and TOML frontmatter
|
|
84
|
+
- Fenced and indented code blocks
|
|
85
|
+
- HTML comments
|
|
86
|
+
- Images, tables, headings and horizontal rules
|
|
87
|
+
- Link reference definitions
|
|
88
|
+
- Hugo shortcodes and Liquid tags
|
|
89
|
+
- HTML and JSX tags
|
|
90
|
+
- Bare URLs
|
|
91
|
+
|
|
92
|
+
These keep their words and lose their markup:
|
|
93
|
+
|
|
94
|
+
- Link text
|
|
95
|
+
- Inline code
|
|
96
|
+
- Bold and emphasis
|
|
97
|
+
- Footnote text
|
|
98
|
+
- List item text
|
|
99
|
+
- Blockquotes (unless `--skip-quotes`)
|
|
100
|
+
|
|
101
|
+
Each paragraph and each list item becomes one block. Blocks are joined with a space, and a block
|
|
102
|
+
with no terminal punctuation gains a full stop. Without it the detector reads a run of list items as
|
|
103
|
+
one sentence.
|
|
104
|
+
|
|
105
|
+
Regions between `<!-- pangram-check:off -->` and `<!-- pangram-check:on -->` are dropped. Use them
|
|
106
|
+
for quoted material.
|
|
107
|
+
|
|
108
|
+
`.md`, `.markdown`, `.mdx` and `.mdoc` get the markdown rules. Everything else is treated as plain
|
|
109
|
+
text, and `--plain` forces that.
|
|
110
|
+
|
|
111
|
+
Every block records the source line it started on. That is where the `file:line` reference on each
|
|
112
|
+
reported window comes from.
|
|
113
|
+
|
|
114
|
+
## The cache
|
|
115
|
+
|
|
116
|
+
`$XDG_CACHE_HOME/pangram-check/` or `~/.cache/pangram-check/`, one JSON file per result, keyed by a
|
|
117
|
+
SHA-256 of the model selector and the extracted prose. The file holds the full API response,
|
|
118
|
+
including the submitted text.
|
|
119
|
+
|
|
120
|
+
Delete the directory to clear it. `--no-cache` writes nothing there.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Reading a result
|
|
2
|
+
|
|
3
|
+
Pangram scores the whole submission and then scores it again in windows, so the useful part of a
|
|
4
|
+
result is the window list.
|
|
5
|
+
|
|
6
|
+
## The document level
|
|
7
|
+
|
|
8
|
+
| Field | What it carries |
|
|
9
|
+
| -------------------------- | --------------------------------------------------------- |
|
|
10
|
+
| `headline` | A short verdict, such as `AI Detected` or `Human Written` |
|
|
11
|
+
| `prediction` | A sentence of detail behind the headline |
|
|
12
|
+
| `prediction_short` | The same in a word or two |
|
|
13
|
+
| `fraction_ai` | Share of the text scored as machine-written, 0 to 1 |
|
|
14
|
+
| `fraction_ai_assisted` | Share scored as written with machine help |
|
|
15
|
+
| `fraction_human` | Share scored as human-written |
|
|
16
|
+
| `num_ai_segments` | Count of segments in each class, alongside the two below |
|
|
17
|
+
| `num_ai_assisted_segments` | |
|
|
18
|
+
| `num_human_segments` | |
|
|
19
|
+
| `version` | The detector version behind the verdict |
|
|
20
|
+
|
|
21
|
+
The three fractions and the three segment counts answer different questions. A document can be 20%
|
|
22
|
+
AI by volume while only one segment out of nine is the source of it, which points at a passage. The
|
|
23
|
+
same 20% spread evenly across every segment points at the whole document.
|
|
24
|
+
|
|
25
|
+
## The window level
|
|
26
|
+
|
|
27
|
+
| Field | What it carries |
|
|
28
|
+
| ---------------------------- | ------------------------------------------------- |
|
|
29
|
+
| `ai_assistance_score` | 0 for human, 1 for machine. The number to sort by |
|
|
30
|
+
| `label` | `Human`, `AI` or `AI Assisted` |
|
|
31
|
+
| `confidence` | How sure the detector is of that label |
|
|
32
|
+
| `text` | The passage that was scored |
|
|
33
|
+
| `start_index`, `end_index` | Character offsets into the submitted prose |
|
|
34
|
+
| `word_count`, `token_length` | The size of the window |
|
|
35
|
+
| `is_humanized` | The passage looks worked over to read as human |
|
|
36
|
+
| `humanizer_score` | How strongly, 0 to 1 |
|
|
37
|
+
|
|
38
|
+
The script turns `start_index` into a `file:line` reference by mapping it back through the
|
|
39
|
+
extraction, so a flagged window points at the paragraph in the source file. Line references are
|
|
40
|
+
accurate to the block, and the column is not tracked.
|
|
41
|
+
|
|
42
|
+
## Which signals to act on
|
|
43
|
+
|
|
44
|
+
**Sort by `ai_assistance_score` and read the top two or three passages.** Everything else in the
|
|
45
|
+
result is context for those.
|
|
46
|
+
|
|
47
|
+
**`is_humanized` outranks a high score.** A high score says a passage reads as machine-written. The
|
|
48
|
+
humanized flag says it reads as machine-written text that has since been edited to look human, which
|
|
49
|
+
is a claim about the editing as well as the drafting.
|
|
50
|
+
|
|
51
|
+
**`confidence` qualifies the label, and it qualifies a low score too.** A `Human` label at low
|
|
52
|
+
confidence is a weaker result than a `Human` label at high confidence.
|
|
53
|
+
|
|
54
|
+
**Short windows are noisier.** A window of 60 words carries less signal than one of 300. Check
|
|
55
|
+
`word_count` before acting on an outlier.
|
|
56
|
+
|
|
57
|
+
## The limits of a verdict
|
|
58
|
+
|
|
59
|
+
Pangram reports whether text reads as machine-generated. Whether the writing is any good is a
|
|
60
|
+
separate question, and a document can come back fully human while being badly organised, wrong, or
|
|
61
|
+
dull.
|
|
62
|
+
|
|
63
|
+
Detectors carry false positives. Published audits have found high false-positive rates on writing by
|
|
64
|
+
non-native English speakers, which is the failure mode to hold in mind before treating a score as
|
|
65
|
+
evidence about a person. One verdict is one input to a rereading.
|
|
66
|
+
|
|
67
|
+
Scores also move for reasons that have nothing to do with quality. Quoted material, standard
|
|
68
|
+
technical phrasing and heavily edited passages all shift the number. Read the passage the score
|
|
69
|
+
points at, and judge the passage.
|