@nurix/etna 0.20.15 → 0.20.16
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/data/harness-index.json +1 -1
- package/data/harnesses/code/CLAUDE.md +8 -2
- package/data/harnesses/code/agents/bookkeeper.md +1 -0
- package/data/harnesses/code/agents/polygraph-finder.md +4 -0
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/awareness.mjs +15 -2
- package/data/harnesses/code/hooks/lib/state.mjs +1 -0
- package/data/harnesses/code/hooks/lib/sweep.mjs +20 -2
- package/data/harnesses/code/skills/documentation/SKILL.md +28 -0
- package/data/harnesses/code/skills/documentation/scripts/comment-strip.mjs +339 -0
- package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +41 -10
- package/data/harnesses/code/skills/documentation/scripts/source-lint.mjs +225 -0
- package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +1 -2
- package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +1 -1
- package/data/harnesses/code/skills/polygraph/SKILL.md +1 -1
- package/data/harnesses/code/skills/testing/SKILL.md +1 -1
- package/data/harnesses/delivery/harness.json +2 -2
- package/data/harnesses/delivery/skills/documentation/SKILL.md +28 -0
- package/data/harnesses/delivery/skills/documentation/scripts/comment-strip.mjs +339 -0
- package/data/harnesses/delivery/skills/documentation/scripts/doc-lint.mjs +41 -10
- package/data/harnesses/delivery/skills/documentation/scripts/source-lint.mjs +225 -0
- package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +1 -2
- package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +1 -1
- package/data/harnesses/etna/harness.json +2 -2
- package/data/harnesses/etna/skills/documentation/SKILL.md +28 -0
- package/data/harnesses/etna/skills/documentation/scripts/comment-strip.mjs +339 -0
- package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +41 -10
- package/data/harnesses/etna/skills/documentation/scripts/source-lint.mjs +225 -0
- package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +1 -2
- package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +1 -1
- package/package.json +2 -2
package/data/harness-index.json
CHANGED
|
@@ -172,8 +172,14 @@ Defaults — ❌ prohibitions hold unless the user explicitly asks otherwise;
|
|
|
172
172
|
|
|
173
173
|
## File Structure
|
|
174
174
|
|
|
175
|
-
|
|
176
|
-
|
|
175
|
+
A file's documentation lives in `docs/`. Its comments carry only what a reader needs with the code in front of them, and the budget is deliberately small — a comment is the one artifact nothing audits, so it is where stale claims accumulate.
|
|
176
|
+
|
|
177
|
+
- Add a file-path header comment at the top of each source file, in the file's native comment syntax — `// src/modules/example.js - Module for handling user authentication`. Place it below any shebang line; skip formats with no comment syntax (JSON, lockfiles) and generated files. **The path is repo-relative and names the file's own path**: a header a move left behind is worse than no header, because it reads as current.
|
|
178
|
+
- **Two or three present-tense sentences, six lines at the ceiling** — what the file is, the invariant a reader must not break, and where the fuller account lives, as a `docs/…` path.
|
|
179
|
+
- Add a 1-line description above each function whose purpose isn't fully evident from its name and signature: `// Extract JWT token from request based on endpoint type`. **One sentence, third-person indicative** — `Installs the package`, never `Install the package` — and a second sentence only to carry a named invariant. A field, type member, or constant earns a sentence only where its name and type do not already say it; a member of an enum or a constant map earns none. No commented-out code — refactor for clarity instead.
|
|
180
|
+
- **Design rationale, alternatives weighed, history, ticket and decision-record references, and dated notes are not comments.** They belong to the owning feature doc, and moving the claim there is part of the same change — cutting one out of a comment without landing it is a deletion, not a cleanup (the `documentation` skill § Where a file's documentation lives).
|
|
181
|
+
- **The ceiling is on what a change adds, not on what it inherits: under a quarter of the lines a change adds are comments.** The measured regression that set this number ran at 28% on new files while the tree it joined sat at 14%, so a whole-file average is the wrong instrument — it grades the density somebody else wrote and passes the prose being added right now. `.claude/skills/documentation/scripts/source-lint.mjs --changed` reports it and the detector raises one row per offending file; a comments-only sweep proves itself with `comment-strip.mjs`, which strips both revisions and diffs what is left, so a code change hiding inside a cleanup cannot pass as one.
|
|
182
|
+
- **The one comment that earns a paragraph is a booby trap** — an edit that looks obviously safe and is not. It names the version or the measurement that makes it true and the condition under which it stops being true, so the next reader can retire it instead of trusting it. Everything else that wants a paragraph wants a doc.
|
|
177
183
|
|
|
178
184
|
---
|
|
179
185
|
|
|
@@ -19,6 +19,7 @@ Ground every edit in this ladder, in order. Never invent content; a fact you can
|
|
|
19
19
|
| --- | --- |
|
|
20
20
|
| `dead-glob` | read the owning doc, find where the files went (`git log --follow`, glob search), update its `owns:` frontmatter |
|
|
21
21
|
| `header-missing` | add the file-path header comment in the file's native comment syntax |
|
|
22
|
+
| `source-lint` | run `.claude/skills/documentation/scripts/source-lint.mjs <file>` and clear its BLOCK findings: a stale or absent header path is rewritten to the file's own repo-relative path, and `comment-ratio-added` — over a quarter of the lines the change added are comments — is cleared by moving the rationale into the owning doc and leaving one sentence per symbol. Never clear either with a `source-lint-disable`: the one comment that earns its length is a booby trap, and that one takes the disable with its reason beside it. The file-level `comment-ratio` warning is **not** yours — it grades density somebody else wrote, and rewriting an inherited file is a unit of work with an owner, not a queue drain |
|
|
22
23
|
| `doc-lint` | fix the finding, or add `<!-- doc-lint-disable: id -->` with a reason beside it when the flagged text is deliberate |
|
|
23
24
|
| `index-row-missing` | add the doc's row to `docs/README.md` |
|
|
24
25
|
| `doc-implicated` | read the diff of the named source file (`git diff` / `git log -p`), and update the owning doc **only where the diff contradicts documented behavior**; if nothing documented changed, the row's resolution is deleting it |
|
|
@@ -17,6 +17,10 @@ Docs state the target design; code is presumed correct unless a standing decisio
|
|
|
17
17
|
2. Read the document once, whole — it is the one file you always read in full. Then check each claim it makes against the code: grep for the mechanism, open the passage with a line range, cite `file:line`.
|
|
18
18
|
3. Write one row per claim to the partial file, **no header**, exactly ten pipe-separated fields: `id|subject|claim|evidence|counter_evidence|verdict|confidence|depends_on|status|resolution`. A literal `|` becomes `¦`, a newline `; `. Verdicts: `COHERENT` (emit at the unit you audited, so coverage is countable) · `MISDESCRIBED` · `UNDOCUMENTED` · `UNBUILT` · `RESIDUE` · `CONTESTED` · `QUESTION`. `id` is `{doc-stem}-NNN`; `status` is `open`; `resolution` is empty; `confidence` is an honest 0–1.
|
|
19
19
|
4. Sweep the other direction once: code inside the `owns:` globs that no claim in the document covers is an `UNDOCUMENTED` row — after the precedent test.
|
|
20
|
+
5. Sweep the comments inside the `owns:` globs once, against the shape in the repo's `CLAUDE.md` § File Structure — the authority, never restated by you. A comment is not a claim about the code, it is a claim *in* the code, and it is the one artifact nothing else audits. Two things come out of this sweep, and a claim worth keeping produces **both**:
|
|
21
|
+
- **The claim, as an `UNDOCUMENTED` row against the document** — design rationale, the alternatives weighed, the measurement behind a constant, the reason an obvious-looking edit is wrong. The applier folds it into the doc, which is what makes cutting the comment safe later. Cite the comment's `file:line` as evidence and state the claim in the document's voice, not the comment's.
|
|
22
|
+
- **The comment itself, as a code-side row** naming the file and the lines — `MISDESCRIBED` where it states something the code does not do, `RESIDUE` where it is history, a ticket or decision-record reference, a dated note, a divider, a per-field block, or a restatement of what the doc now carries. Code-side rows leave as register rows; **you never edit a comment**, whatever the row says.
|
|
23
|
+
Shape breaches are one `RESIDUE` row per file, counted rather than enumerated: no path header, a header naming a path other than its own, a header past three sentences, a function comment past two. **The exception you must not report is the booby trap** — a comment whose subject is a danger on an edit that looks safe, naming the version, measurement, or condition that makes it true. It earns its length where it sits.
|
|
20
24
|
|
|
21
25
|
Rules that bind every row:
|
|
22
26
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"harness": "code",
|
|
3
3
|
"name": "code",
|
|
4
|
-
"version": "3.16.
|
|
4
|
+
"version": "3.16.4",
|
|
5
5
|
"files": [
|
|
6
6
|
".prettierignore",
|
|
7
7
|
".prettierrc.json",
|
|
@@ -25,5 +25,5 @@
|
|
|
25
25
|
}
|
|
26
26
|
],
|
|
27
27
|
"docsRoot": "docs",
|
|
28
|
-
"digest": "
|
|
28
|
+
"digest": "5e98d9973eb1"
|
|
29
29
|
}
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
readDecision,
|
|
36
36
|
} from "./lib/state.mjs";
|
|
37
37
|
import { readSafe, relIfInside, today, SOURCE_EXT, commandRoot, docsRoot } from "./lib/repo.mjs";
|
|
38
|
-
import { sweepResolvedRows, docLint, hasHeaderComment } from "./lib/sweep.mjs";
|
|
38
|
+
import { sweepResolvedRows, docLint, sourceLint, hasHeaderComment } from "./lib/sweep.mjs";
|
|
39
39
|
import { listPlans, polygraphVerdict } from "./lib/plans.mjs";
|
|
40
40
|
import { frozenStatusAtHead } from "./lib/frozen.mjs";
|
|
41
41
|
import { undischargedDocRows } from "./lib/ledger.mjs";
|
|
@@ -369,7 +369,20 @@ function detectorForFile(projectDir, rel, meta, rows) {
|
|
|
369
369
|
...meta,
|
|
370
370
|
kind: "header-missing",
|
|
371
371
|
description: `${rel} lacks its file-path header comment`,
|
|
372
|
-
action: "add the header comment",
|
|
372
|
+
action: "add the header comment naming this file's own repo-relative path",
|
|
373
|
+
})
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
// The blocking rest of the source-comment convention as one row per file — in practice the added-line
|
|
377
|
+
// comment ratio, since every other non-header rule only warns. Header findings are excluded because
|
|
378
|
+
// `header-missing` above already carries them, and two rows for one defect are drained twice.
|
|
379
|
+
if (sourceLint(projectDir, rel).some((line) => !/header-missing|header-path-stale/.test(line))) {
|
|
380
|
+
rows.push(
|
|
381
|
+
formatAutoRow({
|
|
382
|
+
...meta,
|
|
383
|
+
kind: "source-lint",
|
|
384
|
+
description: `${rel} violates the source-comment convention`,
|
|
385
|
+
action: "run .claude/skills/documentation/scripts/source-lint.mjs on it and clear the blocking findings, or source-lint-disable an id with its reason",
|
|
373
386
|
})
|
|
374
387
|
);
|
|
375
388
|
}
|
|
@@ -180,6 +180,7 @@ const ROW_PATH_RULES = [
|
|
|
180
180
|
/·\s*doc-implicated\s*·\s*(\S+)\s+changed;\s+owning doc\s+(\S+?)\s+may need/,
|
|
181
181
|
/·\s*doc-lint\s*·\s*(\S+)\s+has\s/,
|
|
182
182
|
/·\s*header-missing\s*·\s*(\S+)\s+lacks\s/,
|
|
183
|
+
/·\s*source-lint\s*·\s*(\S+)\s+violates\s/,
|
|
183
184
|
/·\s*index-row-missing\s*·\s*(\S+)\s+is not linked from\s+(\S+)/,
|
|
184
185
|
/·\s*package-rejected\s*·\s*(\S+)\s+declares\s/,
|
|
185
186
|
/·\s*frozen-record-edited\s*·\s*(\S+)\s+is a frozen record/,
|
|
@@ -33,10 +33,19 @@ export function docLint(projectDir, rel) {
|
|
|
33
33
|
return out.split("\n").filter((line) => /:\d+ \[(block|warn)\]/.test(line));
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
// True when a source file's
|
|
36
|
+
// True when the first path-shaped token in a source file's opening lines IS its repo-relative path.
|
|
37
37
|
export function hasHeaderComment(projectDir, rel) {
|
|
38
38
|
const head = readSafe(path.join(projectDir, rel)).split("\n").slice(0, 5).join("\n");
|
|
39
|
-
|
|
39
|
+
const m = /([\w.@-]+(?:\/[\w.@-]+)*\.[A-Za-z]{1,5})\b/.exec(head);
|
|
40
|
+
return m ? m[1] === rel : false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The installed source-lint's block-severity lines for one file, so warnings advise and blocking rules wall.
|
|
44
|
+
export function sourceLint(projectDir, rel) {
|
|
45
|
+
const script = path.join(projectDir, ".claude", "skills", "documentation", "scripts", "source-lint.mjs");
|
|
46
|
+
if (!fs.existsSync(script)) return [];
|
|
47
|
+
const res = spawnSync("node", [script, rel], { cwd: projectDir, encoding: "utf8", timeout: 5000 });
|
|
48
|
+
return `${res.stdout || ""}`.split("\n").filter((line) => /:\d+ \[block\]/.test(line));
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
// Verifier keys the detector no longer emits — swept when machine-raised, never taught to authors.
|
|
@@ -52,6 +61,15 @@ export const ROW_VERIFIERS = {
|
|
|
52
61
|
return hasHeaderComment(projectDir, rel);
|
|
53
62
|
},
|
|
54
63
|
|
|
64
|
+
// "<rel> violates the source-comment convention" — dead once source-lint reports no blocking finding.
|
|
65
|
+
"source-lint": (projectDir, body) => {
|
|
66
|
+
const m = /·\s+source-lint\s+·\s+(\S+)\s+violates/.exec(body);
|
|
67
|
+
if (!m) return false;
|
|
68
|
+
const rel = m[1];
|
|
69
|
+
if (!fs.existsSync(path.join(projectDir, rel))) return true;
|
|
70
|
+
return sourceLint(projectDir, rel).length === 0;
|
|
71
|
+
},
|
|
72
|
+
|
|
55
73
|
// "owns: glob `<glob>` in <doc> matches no files" — dead once the glob matches something again.
|
|
56
74
|
"dead-glob": (projectDir, body, ctx) => {
|
|
57
75
|
const m = /`([^`]+)`/.exec(body);
|
|
@@ -11,6 +11,26 @@ Document contracts and architecture, not implementation details. Show function s
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
+
## Where a file's documentation lives
|
|
15
|
+
|
|
16
|
+
**In `docs/`, never in the file's comments** — and this skill is the half of that rule that makes it safe. A source file carries a repo-relative path header of two or three present-tense sentences and one third-person sentence above each non-obvious function; the shape is the harness's `CLAUDE.md` § File Structure, which is canonical and is not restated here. What lands **here** is everything that shape displaces: design rationale, the alternatives weighed, the measurement behind a constant, the reason an obvious-looking change is wrong.
|
|
17
|
+
|
|
18
|
+
**Cutting a comment is a move, not a deletion.** A claim taken out of a source file is owed one of two things before the cut is done, and the choice is per claim, not per file:
|
|
19
|
+
|
|
20
|
+
| The claim is | What it earns |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| Worth keeping, and its doc does not state it | A section in the **owning feature doc** (the ownership index resolves which), carrying the claim and the argument for why the codebase is worse without it — never the comment's wording, which was written for a reader holding the code |
|
|
23
|
+
| Already stated in its doc | Nothing — but you **open that doc and find the sentence** before saying so. An assumed duplicate is how a claim is lost |
|
|
24
|
+
| History, a ticket or decision-record reference, a dated note, a divider, or narration of code that no longer exists | Nothing, and no record. A record lives in `docs/history/`; these are not claims about the present |
|
|
25
|
+
|
|
26
|
+
**Count what you cut, and prove the cut was only comments.** A comment sweep states comment lines before and after per file, so a drop nobody accounted for is visible as a number rather than as a feeling. [`scripts/comment-strip.mjs`](scripts/comment-strip.mjs) turns that from a claim into a check: it strips comments from both revisions — honouring strings, template and character literals, regex literals, raw strings and JSX comment braces — and diffs what is left, so a sweep that quietly changed executable code cannot pass as a cleanup. `comment-strip.mjs --rev <before> --to <after> <paths…>` exits 0 for "no executable code changed" and 1 naming the file and the first diverging line. Run it over any pass that touches more than a handful of files; a sweep without it is an assertion.
|
|
27
|
+
|
|
28
|
+
**The mechanical half of the shape is checked, not remembered.** [`scripts/source-lint.mjs`](scripts/source-lint.mjs) `--changed` reports a header that names another path or none, a change whose added lines are over a quarter comments, and the four shapes the convention deletes outright — narration, ALL-CAPS emphasis, an over-long header, a comment block that has become a document. Block-severity findings must be fixed; the escape hatch is a `source-lint-disable: <id>` comment with the reason in a second comment beside it, and the only finding that legitimately takes one is a booby trap. The harness detector raises a bookkeeping row for the same condition, so an unfixed breach outlives the session that wrote it.
|
|
29
|
+
|
|
30
|
+
**Never convert a booby trap into a one-liner.** A comment whose subject is a danger on an edge that looks safe — a pinned dependency's limitation, a measurement that justifies a narrow allowlist, a two-clock interaction — is load-bearing where it sits, because the reader who needs it is mid-edit and will not go looking. Compress it if it rambles; do not relocate it. The tell is that the comment names a version, a measurement, or a condition under which it expires.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
14
34
|
## Present tense, always
|
|
15
35
|
|
|
16
36
|
**A document states what is — never how it got there, and never how far along it is.** Write as if the current design had always been the plan. This is the most-violated rule in the skill, so it sits at the top; it applies to every document written or edited through this skill, whatever the template.
|
|
@@ -24,6 +44,12 @@ Document contracts and architecture, not implementation details. Show function s
|
|
|
24
44
|
|
|
25
45
|
The journey — dates, migrations, progress, supersessions — lives only in records (`docs/history/`); when reality changes, a truth document simply becomes correct. The full strip-list and the record-side rules: [`references/style-guide.md`](references/style-guide.md) § The Two Natures — Tense & History.
|
|
26
46
|
|
|
47
|
+
**A date in a truth document is a defect, and the list of exceptions is closed.** Not a softened preference: a truth document carries no dated line at all, because a date answers *when did this happen*, which is a record's question, and a reader who needs the answer is reading the wrong file. The four exceptions, and there are no others: the frontmatter's tooling keys (`last-reviewed:`, `last-updated:`, `rev:` — written by tooling, never by you), an Architecture Decision's **Decision Changelog** tail, `docs/structure.md`'s **Health Log** tail, and a dated path inside a link or code span, which is a *citation* of a record rather than a claim about the present.
|
|
48
|
+
|
|
49
|
+
**Cite a record by what it is, never by when it was.** `the dev reset runbook` links to the file; "the 2026-09-13 reset" states a date the reader must now keep in their head and that goes stale as soon as a second reset happens. The link carries the date in its path, where it belongs, and the prose stays true forever.
|
|
50
|
+
|
|
51
|
+
**A verification stamp is a record, not a status line.** "Driven 2026-09-13 · PASS" in a behavioural checklist is the clearest instance of this defect: it reads as documentation and is actually a dated event. The section states what must be true and which suite test guards it; *that* somebody drove it on a Tuesday belongs to the changelog entry of the unit that drove it, and staleness is then visible from something that re-checks itself — a pin that runs in CI — rather than from a date nobody re-stamped. A section with no automated guard says so in its trace, which is a stronger signal than an old date, because it cannot silently become wrong.
|
|
52
|
+
|
|
27
53
|
The mechanical subset of these rules is checked by [`scripts/doc-lint.mjs`](scripts/doc-lint.mjs) — run it over changed docs before finishing. Block-severity findings must be fixed; `<!-- doc-lint-disable: <id> -->` with a reason beside it is the escape hatch. Two invocation traps: the disable comment takes **bare rule ids only** — prose inside the comment silently breaks the parse, so put the reason in a second comment beside it — and the script grades truth-vs-record scope by matching `docs/(history|research|memory)/` against **the path string it is given**, so always hand it a repo-root-relative path; linting a record from inside its own folder grades it as a truth document.
|
|
28
54
|
|
|
29
55
|
---
|
|
@@ -155,3 +181,5 @@ update, and the `maintenance` agent's baseline pass, route there.
|
|
|
155
181
|
| `references/untemplated.md` | How to write a document no template covers (nature, placement, shape) + the co-authoring workflow |
|
|
156
182
|
| `references/examples.md` | Good vs bad documentation examples |
|
|
157
183
|
| `scripts/doc-lint.mjs` | Mechanical checks over written docs (tense narration, dates, stubs, redirects) — run over changed docs before finishing |
|
|
184
|
+
| `scripts/source-lint.mjs` | Mechanical checks over source **comments** (header path identity, the added-line comment ratio, narration, shouting) — run `--changed` before finishing any change that writes source |
|
|
185
|
+
| `scripts/comment-strip.mjs` | The proof a comment sweep changed no executable code: strips comments from two revisions and diffs what is left. Exit 0 is the claim; exit 1 names the file and the first diverging line |
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// skills/documentation/scripts/comment-strip.mjs - Proves a comment sweep changed no executable code.
|
|
3
|
+
// Strips comments from a git revision of each file and from the working copy, then compares the two
|
|
4
|
+
// stripped forms; a difference is a code change hiding inside a comments-only pass. String and
|
|
5
|
+
// template literals, character literals, regex literals, and raw strings are honoured, so a `//`
|
|
6
|
+
// inside a string is code and stays.
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
10
|
+
import { readdirSync } from "node:fs";
|
|
11
|
+
import { join, extname } from "node:path";
|
|
12
|
+
|
|
13
|
+
// Comment syntax per extension. `block` is null where the language has none.
|
|
14
|
+
const DIALECTS = {
|
|
15
|
+
".ts": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
16
|
+
".tsx": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
17
|
+
".mts": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
18
|
+
".js": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
19
|
+
".jsx": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
20
|
+
".mjs": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
21
|
+
".cjs": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: true , sameLineQuotes: true },
|
|
22
|
+
".css": { line: null, block: ["/*", "*/"], strings: `'"`, regex: false , sameLineQuotes: true },
|
|
23
|
+
".rs": { line: "//", block: ["/*", "*/"], strings: `"`, regex: false, rustRaw: true , sameLineQuotes: true },
|
|
24
|
+
".go": { line: "//", block: ["/*", "*/"], strings: `'"\``, regex: false , sameLineQuotes: true },
|
|
25
|
+
".java": { line: "//", block: ["/*", "*/"], strings: `'"`, regex: false , sameLineQuotes: true },
|
|
26
|
+
".swift": { line: "//", block: ["/*", "*/"], strings: `"`, regex: false , sameLineQuotes: true },
|
|
27
|
+
".kt": { line: "//", block: ["/*", "*/"], strings: `"`, regex: false , sameLineQuotes: true },
|
|
28
|
+
".sql": { line: "--", block: ["/*", "*/"], strings: `'"`, regex: false },
|
|
29
|
+
".py": { line: "#", block: null, strings: `'"`, regex: false },
|
|
30
|
+
".sh": { line: "#", block: null, strings: `'"`, regex: false },
|
|
31
|
+
".zsh": { line: "#", block: null, strings: `'"`, regex: false },
|
|
32
|
+
".yml": { line: "#", block: null, strings: `'"`, regex: false },
|
|
33
|
+
".yaml": { line: "#", block: null, strings: `'"`, regex: false },
|
|
34
|
+
".toml": { line: "#", block: null, strings: `'"`, regex: false },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Whether a `/` at this position opens a regex literal rather than dividing — decided by the last
|
|
38
|
+
// significant character, the same way a JavaScript tokenizer decides it.
|
|
39
|
+
function regexCanStart(prev) {
|
|
40
|
+
if (prev === "") return true;
|
|
41
|
+
return !/[\w$)\]]/.test(prev);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The index just past a template literal starting at `i`, following `${…}` interpolations and the
|
|
45
|
+
// templates and quotes nested inside them. A scan that stops at the first backtick it meets closes the
|
|
46
|
+
// OUTER template on an INNER one — `${x ? `<b>${y}</b>` : ""}` — and every quote and comment downstream
|
|
47
|
+
// is then read in the wrong context, which reports a whole file as changed code.
|
|
48
|
+
function templateEnd(source, i) {
|
|
49
|
+
const n = source.length;
|
|
50
|
+
let j = i + 1;
|
|
51
|
+
while (j < n) {
|
|
52
|
+
const c = source[j];
|
|
53
|
+
if (c === "\\") {
|
|
54
|
+
j += 2;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (c === "`") return j + 1;
|
|
58
|
+
if (c === "$" && source[j + 1] === "{") {
|
|
59
|
+
let depth = 1;
|
|
60
|
+
j += 2;
|
|
61
|
+
while (j < n && depth > 0) {
|
|
62
|
+
const d = source[j];
|
|
63
|
+
if (d === "\\") {
|
|
64
|
+
j += 2;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (d === "`") {
|
|
68
|
+
j = templateEnd(source, j);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (d === "'" || d === '"') {
|
|
72
|
+
const quote = d;
|
|
73
|
+
j++;
|
|
74
|
+
while (j < n && source[j] !== quote && source[j] !== "\n") {
|
|
75
|
+
if (source[j] === "\\") j++;
|
|
76
|
+
j++;
|
|
77
|
+
}
|
|
78
|
+
j++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (d === "{") depth++;
|
|
82
|
+
else if (d === "}") depth--;
|
|
83
|
+
j++;
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
j++;
|
|
88
|
+
}
|
|
89
|
+
return n;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Remove every comment from `source`, preserving all other bytes including newlines.
|
|
93
|
+
export function stripComments(source, dialect) {
|
|
94
|
+
const { line, block, strings, regex, rustRaw } = dialect;
|
|
95
|
+
const out = [];
|
|
96
|
+
let i = 0;
|
|
97
|
+
let lastSignificant = "";
|
|
98
|
+
const n = source.length;
|
|
99
|
+
while (i < n) {
|
|
100
|
+
const ch = source[i];
|
|
101
|
+
const two = source.slice(i, i + 2);
|
|
102
|
+
|
|
103
|
+
if (rustRaw && ch === "r" && (source[i + 1] === "#" || source[i + 1] === '"')) {
|
|
104
|
+
let j = i + 1;
|
|
105
|
+
let hashes = 0;
|
|
106
|
+
while (source[j] === "#") {
|
|
107
|
+
hashes++;
|
|
108
|
+
j++;
|
|
109
|
+
}
|
|
110
|
+
if (source[j] === '"') {
|
|
111
|
+
const close = '"' + "#".repeat(hashes);
|
|
112
|
+
const end = source.indexOf(close, j + 1);
|
|
113
|
+
const stop = end === -1 ? n : end + close.length;
|
|
114
|
+
out.push(source.slice(i, stop));
|
|
115
|
+
lastSignificant = '"';
|
|
116
|
+
i = stop;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (line && two.slice(0, line.length) === line) {
|
|
122
|
+
const nl = source.indexOf("\n", i);
|
|
123
|
+
i = nl === -1 ? n : nl;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (block && two === block[0]) {
|
|
128
|
+
const end = source.indexOf(block[1], i + 2);
|
|
129
|
+
const stop = end === -1 ? n : end + block[1].length;
|
|
130
|
+
// A JSX expression container holding only a comment — `{/* … */}` — is comment syntax
|
|
131
|
+
// whole: the braces exist to carry it, so they leave with it. Left behind, a multi-line
|
|
132
|
+
// container collapses to `{}` and reads as a code change.
|
|
133
|
+
let after = stop;
|
|
134
|
+
while (after < n && /\s/.test(source[after])) after++;
|
|
135
|
+
let brace = out.length - 1;
|
|
136
|
+
while (brace >= 0 && out[brace].trim() === "") brace--;
|
|
137
|
+
if (regex && source[after] === "}" && brace >= 0 && out[brace] === "{") {
|
|
138
|
+
out.splice(brace, 1);
|
|
139
|
+
for (const c of source.slice(i, after + 1)) if (c === "\n") out.push("\n");
|
|
140
|
+
i = after + 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
for (const c of source.slice(i, stop)) if (c === "\n") out.push("\n");
|
|
144
|
+
i = stop;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (ch === "`" && regex) {
|
|
149
|
+
const stop = templateEnd(source, i);
|
|
150
|
+
out.push(source.slice(i, stop));
|
|
151
|
+
lastSignificant = "`";
|
|
152
|
+
i = stop;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (strings.includes(ch)) {
|
|
157
|
+
// A quote opens a literal only where one can actually close. In the C family and in Rust a
|
|
158
|
+
// `'` or `"` literal may not contain a raw newline, so requiring the partner on the same
|
|
159
|
+
// line is not a heuristic — it is the grammar, and it is what keeps an apostrophe in JSX
|
|
160
|
+
// text (`Couldn't load`) and a Rust char literal holding a quote (`trim_matches('"')`) from
|
|
161
|
+
// opening a string that swallows the rest of the file. Backticks span lines by design.
|
|
162
|
+
if (ch !== "`" && dialect.sameLineQuotes) {
|
|
163
|
+
let j = i + 1;
|
|
164
|
+
let closed = false;
|
|
165
|
+
while (j < n && source[j] !== "\n") {
|
|
166
|
+
if (source[j] === "\\") {
|
|
167
|
+
j += 2;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (source[j] === ch) {
|
|
171
|
+
closed = true;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
j++;
|
|
175
|
+
}
|
|
176
|
+
if (!closed) {
|
|
177
|
+
out.push(ch);
|
|
178
|
+
lastSignificant = ch;
|
|
179
|
+
i++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
out.push(source.slice(i, j + 1));
|
|
183
|
+
lastSignificant = ch;
|
|
184
|
+
i = j + 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
out.push(ch);
|
|
188
|
+
i++;
|
|
189
|
+
while (i < n) {
|
|
190
|
+
if (source[i] === "\\") {
|
|
191
|
+
out.push(source.slice(i, i + 2));
|
|
192
|
+
i += 2;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
out.push(source[i]);
|
|
196
|
+
if (source[i] === ch) {
|
|
197
|
+
i++;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
i++;
|
|
201
|
+
}
|
|
202
|
+
lastSignificant = ch;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (regex && ch === "/" && regexCanStart(lastSignificant)) {
|
|
207
|
+
let j = i + 1;
|
|
208
|
+
let inClass = false;
|
|
209
|
+
let closed = false;
|
|
210
|
+
while (j < n) {
|
|
211
|
+
const c = source[j];
|
|
212
|
+
if (c === "\\") {
|
|
213
|
+
j += 2;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (c === "\n") break;
|
|
217
|
+
if (c === "[") inClass = true;
|
|
218
|
+
else if (c === "]") inClass = false;
|
|
219
|
+
else if (c === "/" && !inClass) {
|
|
220
|
+
closed = true;
|
|
221
|
+
j++;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
j++;
|
|
225
|
+
}
|
|
226
|
+
if (closed) {
|
|
227
|
+
out.push(source.slice(i, j));
|
|
228
|
+
lastSignificant = "/";
|
|
229
|
+
i = j;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
out.push(ch);
|
|
235
|
+
if (!/\s/.test(ch)) lastSignificant = ch;
|
|
236
|
+
i++;
|
|
237
|
+
}
|
|
238
|
+
// Collapse whitespace so a re-indent left behind by a removed block comment is not a finding.
|
|
239
|
+
return out
|
|
240
|
+
.join("")
|
|
241
|
+
.split("\n")
|
|
242
|
+
.map((l) => l.replace(/\s+/g, " ").trim())
|
|
243
|
+
.filter((l) => l !== "")
|
|
244
|
+
.join("\n");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Comment-only lines in a source: the non-blank lines it has, less the lines that survive stripping.
|
|
248
|
+
// Both sides exclude blanks, so the difference is comments alone; a trailing comment on a code line is
|
|
249
|
+
// correctly not counted, because the line itself survives.
|
|
250
|
+
function countCommentLines(source, dialect) {
|
|
251
|
+
const nonBlank = source.split("\n").filter((l) => l.trim() !== "").length;
|
|
252
|
+
const stripped = stripComments(source, dialect);
|
|
253
|
+
return nonBlank - (stripped === "" ? 0 : stripped.split("\n").length);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function gitShow(rev, path) {
|
|
257
|
+
try {
|
|
258
|
+
return execFileSync("git", ["show", `${rev}:${path}`], { encoding: "utf8", maxBuffer: 1 << 28 });
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function walk(dir, acc = []) {
|
|
265
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
266
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue;
|
|
267
|
+
const full = join(dir, entry.name);
|
|
268
|
+
if (entry.isDirectory()) walk(full, acc);
|
|
269
|
+
else if (DIALECTS[extname(entry.name)]) acc.push(full);
|
|
270
|
+
}
|
|
271
|
+
return acc;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function main(argv) {
|
|
275
|
+
let rev = "HEAD";
|
|
276
|
+
// With `--to`, both sides come from git and the working tree is not read — the mode that
|
|
277
|
+
// audits a sweep somebody already committed.
|
|
278
|
+
let to = null;
|
|
279
|
+
const paths = [];
|
|
280
|
+
for (let i = 0; i < argv.length; i++) {
|
|
281
|
+
if (argv[i] === "--rev") rev = argv[++i];
|
|
282
|
+
else if (argv[i] === "--to") to = argv[++i];
|
|
283
|
+
else if (argv[i] === "--changed") {
|
|
284
|
+
const out = execFileSync("git", ["diff", "--name-only", rev], { encoding: "utf8" });
|
|
285
|
+
paths.push(...out.split("\n").filter(Boolean));
|
|
286
|
+
} else paths.push(argv[i]);
|
|
287
|
+
}
|
|
288
|
+
if (paths.length === 0) {
|
|
289
|
+
const out = execFileSync("git", ["diff", "--name-only", rev], { encoding: "utf8" });
|
|
290
|
+
paths.push(...out.split("\n").filter(Boolean));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const files = [];
|
|
294
|
+
for (const p of paths) {
|
|
295
|
+
// A revision-to-revision run names paths that need not exist in the checkout.
|
|
296
|
+
if (to !== null) {
|
|
297
|
+
if (DIALECTS[extname(p)]) files.push(p);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (!existsSync(p)) continue;
|
|
301
|
+
if (statSync(p).isDirectory()) files.push(...walk(p));
|
|
302
|
+
else if (DIALECTS[extname(p)]) files.push(p);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let changed = 0;
|
|
306
|
+
let checked = 0;
|
|
307
|
+
let commentDelta = 0;
|
|
308
|
+
for (const file of [...new Set(files)]) {
|
|
309
|
+
const dialect = DIALECTS[extname(file)];
|
|
310
|
+
const before = gitShow(rev, file);
|
|
311
|
+
if (before === null) continue;
|
|
312
|
+
const after = to === null ? readFileSync(file, "utf8") : gitShow(to, file);
|
|
313
|
+
if (after === null) continue;
|
|
314
|
+
const a = stripComments(before, dialect);
|
|
315
|
+
const b = stripComments(after, dialect);
|
|
316
|
+
checked++;
|
|
317
|
+
// Count comment lines directly. Differencing raw length against stripped length would fold in the
|
|
318
|
+
// blank lines the strip also drops, and report a comment+blank delta under a comment label.
|
|
319
|
+
const cb = countCommentLines(before, dialect);
|
|
320
|
+
const ca = countCommentLines(after, dialect);
|
|
321
|
+
commentDelta += ca - cb;
|
|
322
|
+
if (a !== b) {
|
|
323
|
+
changed++;
|
|
324
|
+
const al = a.split("\n");
|
|
325
|
+
const bl = b.split("\n");
|
|
326
|
+
let k = 0;
|
|
327
|
+
while (k < al.length && k < bl.length && al[k] === bl[k]) k++;
|
|
328
|
+
console.log(`CODE CHANGED ${file}`);
|
|
329
|
+
console.log(` ${rev}: ${al[k] === undefined ? "(end of file)" : al[k].slice(0, 120)}`);
|
|
330
|
+
console.log(` now: ${bl[k] === undefined ? "(end of file)" : bl[k].slice(0, 120)}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const verdict = changed === 0 ? "no executable code changed" : `${changed} file(s) changed code`;
|
|
335
|
+
console.log(`\n${checked} file(s) compared against ${rev} — ${verdict}; comment lines ${commentDelta >= 0 ? "+" : ""}${commentDelta}.`);
|
|
336
|
+
process.exit(changed === 0 ? 0 : 1);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (import.meta.url === `file://${process.argv[1]}`) main(process.argv.slice(2));
|
|
@@ -17,19 +17,35 @@ const RULES = [
|
|
|
17
17
|
{ id: 'status-glyph', severity: 'warn', scope: 'truth', exempt: /(^|\/)docs\/(tests|operations)\//, pattern: /(^|\s)(✅|⏳|- \[[ x]\])/u, message: 'status glyph / checklist in a truth doc — tracking belongs in a record' },
|
|
18
18
|
// Truth docs are supposed to cite records, and record filenames are dated — so a date inside a link
|
|
19
19
|
// target or a code span is a path, not narration. Strip those before matching.
|
|
20
|
-
{ id: 'date-in-truth-doc', severity: '
|
|
20
|
+
{ id: 'date-in-truth-doc', severity: 'block', scope: 'truth', strip: /\[[^\]]*\]\([^)]*\)|`[^`]*`|^(last-reviewed|last-updated|rev|harness-version):.*$/gm, pattern: /\b20\d{2}-\d{2}-\d{2}\b/, message: 'date in a truth doc — cite the record by name and link; the closed exceptions are the frontmatter tooling keys, an AD Decision Changelog tail and structure.md\'s Health Log tail' },
|
|
21
21
|
];
|
|
22
22
|
|
|
23
23
|
// Sections inside a truth doc where dated history is sanctioned (the two bridges).
|
|
24
24
|
const BRIDGE_HEADINGS = /^#{1,4}\s+(decision changelog|health log)\b/i;
|
|
25
|
-
// Instruction files and generated trees are
|
|
26
|
-
const SKIP =
|
|
27
|
-
|
|
25
|
+
// Instruction files and generated trees are out of scope; the authoring roots are anchored to the repo root.
|
|
26
|
+
const SKIP = /^(\.claude|skills|rules|agents|harness)\/|(^|\/)(node_modules|packages\/[^/]+\/data)\/|(^|\/)(CLAUDE|AGENTS)\.md$|\.bkp\./;
|
|
27
|
+
// `operations/issues/YYYY-MM-DD-{slug}.md` is a sanctioned record genre (`type: issue`, the
|
|
28
|
+
// `issue-bridge` skill's local pack) living outside the record folders, so it is named here too —
|
|
29
|
+
// graded as a truth document it fails the date rule for carrying the date its filename already does.
|
|
30
|
+
const RECORD = /(^|\/)docs\/(history|research|memory)\/|(^|\/)docs\/operations\/issues\//;
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
const RECORD_TYPES = /^(changelog-entry|plan|migration|migration-runbook|research|brainstorm|journal|record|ledger|issue|triage|project-memory|agent-memory)$/;
|
|
33
|
+
const TRUTH_LOCATIONS = /(^|\/)docs\/(features|architecture|tests|acceptance)\//;
|
|
34
|
+
|
|
35
|
+
// The genre a document declares for itself, from its own frontmatter `type:` key, or null.
|
|
36
|
+
function declaredType(text) {
|
|
37
|
+
if (!text.startsWith('---')) return null;
|
|
38
|
+
const end = text.indexOf('\n---', 3);
|
|
39
|
+
const m = /^type:\s*(\S+)\s*$/m.exec(end === -1 ? text : text.slice(0, end));
|
|
40
|
+
return m ? m[1] : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Classify a document: record or truth, the declared genre outranking the path.
|
|
44
|
+
function scopeOf(path, text) {
|
|
31
45
|
if (SKIP.test(path)) return 'skip';
|
|
32
|
-
|
|
46
|
+
if (RECORD.test(path)) return 'record';
|
|
47
|
+
const declared = text === undefined ? null : declaredType(text);
|
|
48
|
+
return declared && RECORD_TYPES.test(declared) ? 'record' : 'truth';
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
// Yield prose lines only: frontmatter, fenced code blocks, and bridge sections drop out.
|
|
@@ -37,13 +53,21 @@ function proseLines(text) {
|
|
|
37
53
|
const lines = text.split('\n');
|
|
38
54
|
const out = [];
|
|
39
55
|
let inFence = false;
|
|
56
|
+
let fenceMarker = '';
|
|
40
57
|
let inFrontmatter = lines[0] === '---';
|
|
41
58
|
let inBridge = false;
|
|
42
59
|
let bridgeLevel = 0;
|
|
43
60
|
for (let i = inFrontmatter ? 1 : 0; i < lines.length; i++) {
|
|
44
61
|
const line = lines[i];
|
|
45
62
|
if (inFrontmatter) { if (line === '---') inFrontmatter = false; continue; }
|
|
46
|
-
|
|
63
|
+
// A fence closes only on a marker at least as long as the one that opened it, so a nested ```yaml
|
|
64
|
+
// inside a ```markdown block does not invert the state and expose the outer block as prose.
|
|
65
|
+
const fence = /^\s*(`{3,}|~{3,})/.exec(line);
|
|
66
|
+
if (fence) {
|
|
67
|
+
if (!inFence) { inFence = true; fenceMarker = fence[1]; }
|
|
68
|
+
else if (fence[1][0] === fenceMarker[0] && fence[1].length >= fenceMarker.length) inFence = false;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
47
71
|
if (inFence) continue;
|
|
48
72
|
const heading = line.match(/^(#{1,4})\s/);
|
|
49
73
|
if (heading) {
|
|
@@ -64,9 +88,9 @@ function fileDisables(text) {
|
|
|
64
88
|
}
|
|
65
89
|
|
|
66
90
|
function lintFile(path, findings) {
|
|
67
|
-
|
|
68
|
-
if (scope === 'skip' || !existsSync(path)) return;
|
|
91
|
+
if (SKIP.test(path) || !existsSync(path)) return;
|
|
69
92
|
const text = readFileSync(path, 'utf8');
|
|
93
|
+
const scope = scopeOf(path, text);
|
|
70
94
|
const disabled = fileDisables(text);
|
|
71
95
|
const lines = proseLines(text);
|
|
72
96
|
|
|
@@ -85,6 +109,13 @@ function lintFile(path, findings) {
|
|
|
85
109
|
}
|
|
86
110
|
}
|
|
87
111
|
|
|
112
|
+
// A record genre declared in a settled truth location is a contradiction, not a classification.
|
|
113
|
+
if (TRUTH_LOCATIONS.test(path)) {
|
|
114
|
+
const declared = declaredType(text);
|
|
115
|
+
if (declared && RECORD_TYPES.test(declared) && !disabled.has('record-type-in-truth-location'))
|
|
116
|
+
findings.push({ path, n: 1, severity: 'block', id: 'record-type-in-truth-location', message: `\`type: ${declared}\` is a record genre declared in a truth location — move the file to \`docs/history/\` if it is a record, or give it its location's genre` });
|
|
117
|
+
}
|
|
118
|
+
|
|
88
119
|
// A folder index (Readme.md/README.md) is a browse page, not a feature doc — it owns no code.
|
|
89
120
|
const isFolderIndex = /(^|\/)(readme)\.md$/i.test(path);
|
|
90
121
|
if (scope === 'truth' && !isFolderIndex && /(^|\/)docs\/features\/[^/]+\.md$/.test(path)) {
|