@noir-ai/cli 1.15.0 → 1.16.0
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 +1 -1
- package/dist/bin.js +806 -178
- package/dist/bin.js.map +1 -1
- package/dist/hygiene-scan.d.ts +65 -0
- package/dist/hygiene-scan.js +165 -0
- package/dist/hygiene-scan.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +836 -210
- package/dist/index.js.map +1 -1
- package/dist/tui/{chunk-5KWETEMF.js → chunk-6AUAIEFG.js} +668 -170
- package/dist/tui/chunk-6AUAIEFG.js.map +1 -0
- package/dist/tui/{chunk-TPNJEN4B.js → chunk-CQU2INJH.js} +2 -2
- package/dist/tui/{chunk-KETMM3BA.js → chunk-KVJIPKVE.js} +133 -20
- package/dist/tui/chunk-KVJIPKVE.js.map +1 -0
- package/dist/tui/{create-6KDIFPUL.js → create-ZZN7XFLG.js} +6 -4
- package/dist/tui/create-ZZN7XFLG.js.map +1 -0
- package/dist/tui/index.js +37 -19
- package/dist/tui/index.js.map +1 -1
- package/dist/tui/{install-KA35ONQP.js → install-R5U2WTAQ.js} +2 -2
- package/dist/tui/{sync-AII5WCRO.js → sync-GHEPFPLX.js} +6 -4
- package/dist/tui/sync-GHEPFPLX.js.map +1 -0
- package/dist/tui/{update-XGKJ4KBR.js → update-ZFOLOSZ2.js} +3 -3
- package/package.json +18 -12
- package/dist/tui/chunk-5KWETEMF.js.map +0 -1
- package/dist/tui/chunk-KETMM3BA.js.map +0 -1
- package/dist/tui/create-6KDIFPUL.js.map +0 -1
- package/dist/tui/sync-AII5WCRO.js.map +0 -1
- /package/dist/tui/{chunk-TPNJEN4B.js.map → chunk-CQU2INJH.js.map} +0 -0
- /package/dist/tui/{install-KA35ONQP.js.map → install-R5U2WTAQ.js.map} +0 -0
- /package/dist/tui/{update-XGKJ4KBR.js.map → update-ZFOLOSZ2.js.map} +0 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { HygieneTier } from '@noir-ai/skills';
|
|
2
|
+
|
|
3
|
+
/** How many findings the detail cell names before it reports the rest as a
|
|
4
|
+
* count. A tree that has drifted can carry hundreds, and the row is a signal
|
|
5
|
+
* to act on, not an inventory; the counts still cover every finding. */
|
|
6
|
+
declare const HYGIENE_FINDING_CAP = 5;
|
|
7
|
+
/** The largest file the scan reads. A file this big is generated data or a
|
|
8
|
+
* bundle rather than the source and prose the rules are written for. */
|
|
9
|
+
declare const HYGIENE_MAX_FILE_BYTES: number;
|
|
10
|
+
/** The most files one scan reads, so the check stays responsive in a tree far
|
|
11
|
+
* larger than this repository's. Reaching it is reported in the row. */
|
|
12
|
+
declare const HYGIENE_MAX_FILES = 2000;
|
|
13
|
+
/** One finding, reduced to what the report needs. */
|
|
14
|
+
interface HygieneScanFinding {
|
|
15
|
+
path: string;
|
|
16
|
+
/** 1-based line number. */
|
|
17
|
+
line: number;
|
|
18
|
+
/** The rule id, exactly as the rule table declares it. */
|
|
19
|
+
id: string;
|
|
20
|
+
tier: HygieneTier;
|
|
21
|
+
}
|
|
22
|
+
interface HygieneScanResult {
|
|
23
|
+
/** Files read. */
|
|
24
|
+
scanned: number;
|
|
25
|
+
/** Files the scan did not read because they exceed the size cap. */
|
|
26
|
+
skipped: number;
|
|
27
|
+
/** True when the file cap stopped the scan before it read the whole tree, so
|
|
28
|
+
* the counts below describe a prefix of it. */
|
|
29
|
+
truncated: boolean;
|
|
30
|
+
/** Every finding, failures first, then in reading order across the tree. */
|
|
31
|
+
findings: HygieneScanFinding[];
|
|
32
|
+
fail: number;
|
|
33
|
+
warn: number;
|
|
34
|
+
}
|
|
35
|
+
/** The counts line: how many findings of each tier, in how many files, and what
|
|
36
|
+
* the scan itself omitted. It names no location, so a caller that prints every
|
|
37
|
+
* finding on a line of its own pairs this with those lines instead of printing
|
|
38
|
+
* the same findings twice. */
|
|
39
|
+
declare function hygieneCounts(result: HygieneScanResult): string;
|
|
40
|
+
/** The counts line followed by the locations that carry them, up to the cap.
|
|
41
|
+
* Failures are named before warnings, so a truncated list still shows what
|
|
42
|
+
* blocks. This is the doctor row, which has one line to work with. */
|
|
43
|
+
declare function hygieneDetail(result: HygieneScanResult): string;
|
|
44
|
+
/**
|
|
45
|
+
* Reads the repository's own source and documents through `checkHygiene` and
|
|
46
|
+
* returns the scan result: the files read, the findings (failures first), and
|
|
47
|
+
* the fail/warn counts. It pushes no doctor row — that is the caller's
|
|
48
|
+
* business (`checkOutputHygiene` in the doctor command, or the CI gate's own
|
|
49
|
+
* report).
|
|
50
|
+
*
|
|
51
|
+
* Nothing is read unless {@link hasScannableLayout} holds, so a repository
|
|
52
|
+
* without the layout gets an empty result and pays one `stat` per candidate.
|
|
53
|
+
*
|
|
54
|
+
* Each file is read once, and a file that carries the exemption marker comes
|
|
55
|
+
* back from `checkHygiene` with no findings at all, so the marker costs one
|
|
56
|
+
* scan of a file's text rather than a path list kept here. Files past the size
|
|
57
|
+
* cap and files past the count cap are left out; the result says so.
|
|
58
|
+
*
|
|
59
|
+
* The scan never throws and never writes: an unreadable file is skipped, and
|
|
60
|
+
* the commands that repair the tree (`noir skills lint`, the repository's own
|
|
61
|
+
* sweep) are the caller's business.
|
|
62
|
+
*/
|
|
63
|
+
declare function scanOutputHygiene(root: string): HygieneScanResult;
|
|
64
|
+
|
|
65
|
+
export { HYGIENE_FINDING_CAP, HYGIENE_MAX_FILES, HYGIENE_MAX_FILE_BYTES, type HygieneScanFinding, type HygieneScanResult, hygieneCounts, hygieneDetail, scanOutputHygiene };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// src/hygiene-scan.ts
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { checkHygiene } from "@noir-ai/skills";
|
|
5
|
+
var HYGIENE_FINDING_CAP = 5;
|
|
6
|
+
var HYGIENE_MAX_FILE_BYTES = 512 * 1024;
|
|
7
|
+
var HYGIENE_MAX_FILES = 2e3;
|
|
8
|
+
var HYGIENE_CODE_EXTENSIONS = /\.(?:ts|tsx|js|jsx|mjs|cjs|sh)$/i;
|
|
9
|
+
var HYGIENE_MARKDOWN_EXTENSIONS = /\.(?:md|mdx)$/i;
|
|
10
|
+
var HYGIENE_SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "coverage"]);
|
|
11
|
+
var HYGIENE_EXCLUDED_DIRS = /* @__PURE__ */ new Set(["docs/internal", "docs/decisions", "docs/roadmap"]);
|
|
12
|
+
var HYGIENE_EXCLUDED_FILES = /* @__PURE__ */ new Set(["CHANGELOG.md"]);
|
|
13
|
+
function readDirEntries(dir) {
|
|
14
|
+
try {
|
|
15
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
16
|
+
} catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function isDirectory(path) {
|
|
21
|
+
try {
|
|
22
|
+
return statSync(path).isDirectory();
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function hygieneKindOf(path) {
|
|
28
|
+
if (HYGIENE_MARKDOWN_EXTENSIONS.test(path)) return "markdown";
|
|
29
|
+
if (HYGIENE_CODE_EXTENSIONS.test(path)) return "code";
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function hasScannableLayout(root) {
|
|
33
|
+
if (isDirectory(join(root, "docs")) || isDirectory(join(root, "scripts"))) return true;
|
|
34
|
+
for (const entry of readDirEntries(join(root, "packages"))) {
|
|
35
|
+
if (!entry.isDirectory()) continue;
|
|
36
|
+
if (isDirectory(join(root, "packages", entry.name, "src"))) return true;
|
|
37
|
+
if (isDirectory(join(root, "packages", entry.name, "test"))) return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
function addHygieneFile(rel, walk) {
|
|
42
|
+
const name = rel.slice(rel.lastIndexOf("/") + 1);
|
|
43
|
+
if (name.startsWith(".") || HYGIENE_EXCLUDED_FILES.has(name)) return;
|
|
44
|
+
const kind = hygieneKindOf(rel);
|
|
45
|
+
if (kind === null) return;
|
|
46
|
+
if (walk.files.length >= HYGIENE_MAX_FILES) {
|
|
47
|
+
walk.truncated = true;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
walk.files.push({ path: rel, kind });
|
|
51
|
+
}
|
|
52
|
+
function walkScannable(root, relDir, skip, walk) {
|
|
53
|
+
if (walk.truncated) return;
|
|
54
|
+
for (const entry of readDirEntries(join(root, relDir))) {
|
|
55
|
+
if (walk.truncated) return;
|
|
56
|
+
const rel = `${relDir}/${entry.name}`;
|
|
57
|
+
if (entry.isDirectory()) {
|
|
58
|
+
if (HYGIENE_SKIP_DIRS.has(entry.name) || entry.name.startsWith(".") || skip.has(rel)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
walkScannable(root, rel, skip, walk);
|
|
62
|
+
} else if (entry.isFile()) {
|
|
63
|
+
addHygieneFile(rel, walk);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
var NO_SKIP_DIRS = /* @__PURE__ */ new Set();
|
|
68
|
+
function collectHygieneFiles(root) {
|
|
69
|
+
const walk = { files: [], truncated: false };
|
|
70
|
+
for (const entry of readDirEntries(root)) {
|
|
71
|
+
if (entry.isFile()) addHygieneFile(entry.name, walk);
|
|
72
|
+
}
|
|
73
|
+
walkScannable(root, "docs", HYGIENE_EXCLUDED_DIRS, walk);
|
|
74
|
+
walkScannable(root, ".claude/skills", NO_SKIP_DIRS, walk);
|
|
75
|
+
for (const entry of readDirEntries(join(root, "packages"))) {
|
|
76
|
+
if (!entry.isDirectory()) continue;
|
|
77
|
+
walkScannable(root, `packages/${entry.name}/src`, NO_SKIP_DIRS, walk);
|
|
78
|
+
walkScannable(root, `packages/${entry.name}/test`, NO_SKIP_DIRS, walk);
|
|
79
|
+
}
|
|
80
|
+
walkScannable(root, "scripts", NO_SKIP_DIRS, walk);
|
|
81
|
+
walk.files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
82
|
+
return walk;
|
|
83
|
+
}
|
|
84
|
+
function hygieneOmissions(result) {
|
|
85
|
+
const notes = [];
|
|
86
|
+
if (result.skipped > 0) {
|
|
87
|
+
notes.push(
|
|
88
|
+
`${result.skipped} file${result.skipped === 1 ? "" : "s"} over ${HYGIENE_MAX_FILE_BYTES / 1024} KiB skipped`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (result.truncated) notes.push(`stopped at ${HYGIENE_MAX_FILES} files`);
|
|
92
|
+
return notes.length > 0 ? ` (${notes.join("; ")})` : "";
|
|
93
|
+
}
|
|
94
|
+
function hygieneCounts(result) {
|
|
95
|
+
const omitted = hygieneOmissions(result);
|
|
96
|
+
if (result.scanned === 0) {
|
|
97
|
+
return `nothing to scan (no packages/*/src, packages/*/test, scripts/ or documents found)${omitted}`;
|
|
98
|
+
}
|
|
99
|
+
if (result.findings.length === 0) {
|
|
100
|
+
return `clean \u2014 ${result.scanned} file${result.scanned === 1 ? "" : "s"} scanned${omitted}`;
|
|
101
|
+
}
|
|
102
|
+
const files = new Set(result.findings.map((f) => f.path)).size;
|
|
103
|
+
return `${result.fail} fail, ${result.warn} warn in ${files} file${files === 1 ? "" : "s"}${omitted}`;
|
|
104
|
+
}
|
|
105
|
+
function hygieneDetail(result) {
|
|
106
|
+
const counts = hygieneCounts(result);
|
|
107
|
+
if (result.scanned === 0 || result.findings.length === 0) return counts;
|
|
108
|
+
const named = result.findings.slice(0, HYGIENE_FINDING_CAP);
|
|
109
|
+
const hidden = result.findings.length - named.length;
|
|
110
|
+
const where = named.map((f) => `${f.path}:${f.line} ${f.id}`).join("; ");
|
|
111
|
+
return `${counts} \u2014 ${where}${hidden > 0 ? ` (+${hidden} more)` : ""}`;
|
|
112
|
+
}
|
|
113
|
+
function scanOutputHygiene(root) {
|
|
114
|
+
const empty = {
|
|
115
|
+
scanned: 0,
|
|
116
|
+
skipped: 0,
|
|
117
|
+
truncated: false,
|
|
118
|
+
findings: [],
|
|
119
|
+
fail: 0,
|
|
120
|
+
warn: 0
|
|
121
|
+
};
|
|
122
|
+
if (!hasScannableLayout(root)) return empty;
|
|
123
|
+
const walk = collectHygieneFiles(root);
|
|
124
|
+
const findings = [];
|
|
125
|
+
let scanned = 0;
|
|
126
|
+
let skipped = 0;
|
|
127
|
+
for (const file of walk.files) {
|
|
128
|
+
const abs = join(root, file.path);
|
|
129
|
+
try {
|
|
130
|
+
if (statSync(abs).size > HYGIENE_MAX_FILE_BYTES) {
|
|
131
|
+
skipped++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const text = readFileSync(abs, "utf8");
|
|
135
|
+
scanned++;
|
|
136
|
+
for (const found of checkHygiene(text, file.kind)) {
|
|
137
|
+
findings.push({ path: file.path, line: found.line, id: found.id, tier: found.tier });
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
findings.sort((a, b) => {
|
|
143
|
+
if (a.tier !== b.tier) return a.tier === "fail" ? -1 : 1;
|
|
144
|
+
if (a.path !== b.path) return a.path < b.path ? -1 : 1;
|
|
145
|
+
return a.line - b.line;
|
|
146
|
+
});
|
|
147
|
+
const fail = findings.filter((f) => f.tier === "fail").length;
|
|
148
|
+
return {
|
|
149
|
+
scanned,
|
|
150
|
+
skipped,
|
|
151
|
+
truncated: walk.truncated,
|
|
152
|
+
findings,
|
|
153
|
+
fail,
|
|
154
|
+
warn: findings.length - fail
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
export {
|
|
158
|
+
HYGIENE_FINDING_CAP,
|
|
159
|
+
HYGIENE_MAX_FILES,
|
|
160
|
+
HYGIENE_MAX_FILE_BYTES,
|
|
161
|
+
hygieneCounts,
|
|
162
|
+
hygieneDetail,
|
|
163
|
+
scanOutputHygiene
|
|
164
|
+
};
|
|
165
|
+
//# sourceMappingURL=hygiene-scan.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/hygiene-scan.ts"],"sourcesContent":["// Output hygiene — the repository-facing scan.\n//\n// The repository's own source and documents are checked against the hygiene\n// rules @noir-ai/skills declares: the patterns that make text read as machine\n// output. A fail-tier pattern is objectively mechanical (a divider drawn in\n// punctuation, numbered narration, a decorative emoji, a forbidden residue\n// token), so it blocks; a warn-tier pattern is a judgement call (a long comment\n// block, a marker nobody can act on), which would stall legitimate work if it\n// blocked, so it warns.\n//\n// The scan reads the repository's own text only. Generated and vendored trees,\n// the planning corpus, and formats the rules cannot read (JSON, an image\n// fixture) are out of scope; a file states its own exemption with the marker\n// the rules honour, so no path list is kept here.\n//\n// The scan runs only where the layout it is written for exists: a package\n// tree, `scripts/`, or `docs/`. Anywhere else — an ordinary application\n// repository, which is a README and little more — it reports an empty result\n// and reads nothing, root documents included. These rules judge the text this\n// project writes, not the prose of whoever happened to run the command.\n//\n// This module is the single source of truth for that scope: which files are\n// read, which directories are excluded, and the caps. `noir doctor` (via\n// `checkOutputHygiene` in ./commands/doctor.ts) and the CI gate\n// (scripts/hygiene-gate.mjs) both call `scanOutputHygiene`, so the two cannot\n// drift apart.\n\nimport { type Dirent, readdirSync, readFileSync, statSync } from 'node:fs';\nimport { join } from 'node:path';\nimport { checkHygiene, type HygieneKind, type HygieneTier } from '@noir-ai/skills';\n\n/** How many findings the detail cell names before it reports the rest as a\n * count. A tree that has drifted can carry hundreds, and the row is a signal\n * to act on, not an inventory; the counts still cover every finding. */\nexport const HYGIENE_FINDING_CAP = 5;\n\n/** The largest file the scan reads. A file this big is generated data or a\n * bundle rather than the source and prose the rules are written for. */\nexport const HYGIENE_MAX_FILE_BYTES = 512 * 1024;\n\n/** The most files one scan reads, so the check stays responsive in a tree far\n * larger than this repository's. Reaching it is reported in the row. */\nexport const HYGIENE_MAX_FILES = 2000;\n\n/** The source extensions the check reads. Anything else is skipped: the rules\n * are written for comments and for prose, and a data format (JSON, YAML) has\n * no comment for them to read, while a binary fixture would only produce\n * nonsense. */\nconst HYGIENE_CODE_EXTENSIONS = /\\.(?:ts|tsx|js|jsx|mjs|cjs|sh)$/i;\n\n/** The document extensions the check reads as prose. */\nconst HYGIENE_MARKDOWN_EXTENSIONS = /\\.(?:md|mdx)$/i;\n\n/** Directories the walk never descends into: dependencies, build output and\n * coverage. A dot-directory is skipped too (`.git`, `.noir`, local scratch\n * directories), which is how the maintainer's exclusion of session scratch is\n * honoured by construction rather than by naming it here. */\nconst HYGIENE_SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage']);\n\n/** Directories excluded from the scan by the maintainer's decision: the\n * planning corpus, where decisions are recorded in the maintainer's own\n * shorthand. Root-relative POSIX paths. */\nconst HYGIENE_EXCLUDED_DIRS = new Set(['docs/internal', 'docs/decisions', 'docs/roadmap']);\n\n/** The release log, excluded by basename wherever it sits: the root copy is the\n * single source of truth and `docs/CHANGELOG.md` is a pointer to it. */\nconst HYGIENE_EXCLUDED_FILES = new Set(['CHANGELOG.md']);\n\n/** A file the scan will read, with the kind of text it holds. */\ninterface HygieneScanFile {\n /** Root-relative POSIX path. */\n path: string;\n kind: HygieneKind;\n}\n\n/** One finding, reduced to what the report needs. */\nexport interface HygieneScanFinding {\n path: string;\n /** 1-based line number. */\n line: number;\n /** The rule id, exactly as the rule table declares it. */\n id: string;\n tier: HygieneTier;\n}\n\nexport interface HygieneScanResult {\n /** Files read. */\n scanned: number;\n /** Files the scan did not read because they exceed the size cap. */\n skipped: number;\n /** True when the file cap stopped the scan before it read the whole tree, so\n * the counts below describe a prefix of it. */\n truncated: boolean;\n /** Every finding, failures first, then in reading order across the tree. */\n findings: HygieneScanFinding[];\n fail: number;\n warn: number;\n}\n\n/** A walk in progress: the files it has accepted, and whether the file cap has\n * stopped it. */\ninterface HygieneWalk {\n files: HygieneScanFile[];\n truncated: boolean;\n}\n\n/** `readdirSync` entries, or none when the directory does not exist. */\nfunction readDirEntries(dir: string): Dirent[] {\n try {\n return readdirSync(dir, { withFileTypes: true });\n } catch {\n return []; // absent or unreadable — there is nothing to scan\n }\n}\n\n/** Whether `path` is an existing directory. */\nfunction isDirectory(path: string): boolean {\n try {\n return statSync(path).isDirectory();\n } catch {\n return false;\n }\n}\n\n/** The kind of text a path holds, or `null` when the rules cannot read it. */\nfunction hygieneKindOf(path: string): HygieneKind | null {\n if (HYGIENE_MARKDOWN_EXTENSIONS.test(path)) return 'markdown';\n if (HYGIENE_CODE_EXTENSIONS.test(path)) return 'code';\n return null;\n}\n\n/** The layout this check exists for: a package source or test tree, `scripts/`,\n * or a documentation tree. A repository that has none of them is not the\n * audience of these rules, and nothing is read there. */\nfunction hasScannableLayout(root: string): boolean {\n if (isDirectory(join(root, 'docs')) || isDirectory(join(root, 'scripts'))) return true;\n for (const entry of readDirEntries(join(root, 'packages'))) {\n if (!entry.isDirectory()) continue;\n if (isDirectory(join(root, 'packages', entry.name, 'src'))) return true;\n if (isDirectory(join(root, 'packages', entry.name, 'test'))) return true;\n }\n return false;\n}\n\n/** Adds `rel` to the walk when it is a file the rules can read and the walk has\n * room for it. A dot-file is never read (a dot-directory is skipped by the\n * walk itself), and once the file cap is reached the walk records the\n * truncation instead of adding more. */\nfunction addHygieneFile(rel: string, walk: HygieneWalk): void {\n const name = rel.slice(rel.lastIndexOf('/') + 1);\n if (name.startsWith('.') || HYGIENE_EXCLUDED_FILES.has(name)) return;\n const kind = hygieneKindOf(rel);\n if (kind === null) return;\n if (walk.files.length >= HYGIENE_MAX_FILES) {\n walk.truncated = true;\n return;\n }\n walk.files.push({ path: rel, kind });\n}\n\n/** Offers every readable file under `root`/`relDir` to the walk. Symlinked\n * directories are not followed: a link out of the tree is not the\n * repository's own source, and a link to an ancestor would not terminate. */\nfunction walkScannable(\n root: string,\n relDir: string,\n skip: ReadonlySet<string>,\n walk: HygieneWalk,\n): void {\n if (walk.truncated) return;\n for (const entry of readDirEntries(join(root, relDir))) {\n if (walk.truncated) return;\n const rel = `${relDir}/${entry.name}`;\n if (entry.isDirectory()) {\n if (HYGIENE_SKIP_DIRS.has(entry.name) || entry.name.startsWith('.') || skip.has(rel)) {\n continue;\n }\n walkScannable(root, rel, skip, walk);\n } else if (entry.isFile()) {\n addHygieneFile(rel, walk);\n }\n }\n}\n\nconst NO_SKIP_DIRS: ReadonlySet<string> = new Set();\n\n/** The files the check reads: documents at the root and under `docs/` (minus\n * the planning corpus), the repository-authored agent skills, each package's\n * sources and tests, and `scripts/`. Sorted by path so a report is the same on\n * every run. Called only where {@link hasScannableLayout} holds. */\nfunction collectHygieneFiles(root: string): HygieneWalk {\n const walk: HygieneWalk = { files: [], truncated: false };\n for (const entry of readDirEntries(root)) {\n if (entry.isFile()) addHygieneFile(entry.name, walk);\n }\n walkScannable(root, 'docs', HYGIENE_EXCLUDED_DIRS, walk);\n walkScannable(root, '.claude/skills', NO_SKIP_DIRS, walk);\n for (const entry of readDirEntries(join(root, 'packages'))) {\n if (!entry.isDirectory()) continue;\n walkScannable(root, `packages/${entry.name}/src`, NO_SKIP_DIRS, walk);\n walkScannable(root, `packages/${entry.name}/test`, NO_SKIP_DIRS, walk);\n }\n walkScannable(root, 'scripts', NO_SKIP_DIRS, walk);\n walk.files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));\n return walk;\n}\n\n/** What the scan left out, in one parenthesis. Empty when it left out nothing,\n * so a complete report carries no note. */\nfunction hygieneOmissions(result: HygieneScanResult): string {\n const notes: string[] = [];\n if (result.skipped > 0) {\n notes.push(\n `${result.skipped} file${result.skipped === 1 ? '' : 's'} over ${HYGIENE_MAX_FILE_BYTES / 1024} KiB skipped`,\n );\n }\n if (result.truncated) notes.push(`stopped at ${HYGIENE_MAX_FILES} files`);\n return notes.length > 0 ? ` (${notes.join('; ')})` : '';\n}\n\n/** The counts line: how many findings of each tier, in how many files, and what\n * the scan itself omitted. It names no location, so a caller that prints every\n * finding on a line of its own pairs this with those lines instead of printing\n * the same findings twice. */\nexport function hygieneCounts(result: HygieneScanResult): string {\n const omitted = hygieneOmissions(result);\n if (result.scanned === 0) {\n return `nothing to scan (no packages/*/src, packages/*/test, scripts/ or documents found)${omitted}`;\n }\n if (result.findings.length === 0) {\n return `clean — ${result.scanned} file${result.scanned === 1 ? '' : 's'} scanned${omitted}`;\n }\n const files = new Set(result.findings.map((f) => f.path)).size;\n return `${result.fail} fail, ${result.warn} warn in ${files} file${files === 1 ? '' : 's'}${omitted}`;\n}\n\n/** The counts line followed by the locations that carry them, up to the cap.\n * Failures are named before warnings, so a truncated list still shows what\n * blocks. This is the doctor row, which has one line to work with. */\nexport function hygieneDetail(result: HygieneScanResult): string {\n const counts = hygieneCounts(result);\n if (result.scanned === 0 || result.findings.length === 0) return counts;\n const named = result.findings.slice(0, HYGIENE_FINDING_CAP);\n const hidden = result.findings.length - named.length;\n const where = named.map((f) => `${f.path}:${f.line} ${f.id}`).join('; ');\n return `${counts} — ${where}${hidden > 0 ? ` (+${hidden} more)` : ''}`;\n}\n\n/**\n * Reads the repository's own source and documents through `checkHygiene` and\n * returns the scan result: the files read, the findings (failures first), and\n * the fail/warn counts. It pushes no doctor row — that is the caller's\n * business (`checkOutputHygiene` in the doctor command, or the CI gate's own\n * report).\n *\n * Nothing is read unless {@link hasScannableLayout} holds, so a repository\n * without the layout gets an empty result and pays one `stat` per candidate.\n *\n * Each file is read once, and a file that carries the exemption marker comes\n * back from `checkHygiene` with no findings at all, so the marker costs one\n * scan of a file's text rather than a path list kept here. Files past the size\n * cap and files past the count cap are left out; the result says so.\n *\n * The scan never throws and never writes: an unreadable file is skipped, and\n * the commands that repair the tree (`noir skills lint`, the repository's own\n * sweep) are the caller's business.\n */\nexport function scanOutputHygiene(root: string): HygieneScanResult {\n const empty: HygieneScanResult = {\n scanned: 0,\n skipped: 0,\n truncated: false,\n findings: [],\n fail: 0,\n warn: 0,\n };\n if (!hasScannableLayout(root)) return empty;\n const walk = collectHygieneFiles(root);\n const findings: HygieneScanFinding[] = [];\n let scanned = 0;\n let skipped = 0;\n for (const file of walk.files) {\n const abs = join(root, file.path);\n try {\n if (statSync(abs).size > HYGIENE_MAX_FILE_BYTES) {\n skipped++;\n continue;\n }\n const text = readFileSync(abs, 'utf8');\n scanned++;\n for (const found of checkHygiene(text, file.kind)) {\n findings.push({ path: file.path, line: found.line, id: found.id, tier: found.tier });\n }\n } catch {\n // An unreadable path says nothing about hygiene — the store check owns\n // broken files — so the scan moves on to the next one.\n }\n }\n findings.sort((a, b) => {\n if (a.tier !== b.tier) return a.tier === 'fail' ? -1 : 1;\n if (a.path !== b.path) return a.path < b.path ? -1 : 1;\n return a.line - b.line;\n });\n const fail = findings.filter((f) => f.tier === 'fail').length;\n return {\n scanned,\n skipped,\n truncated: walk.truncated,\n findings,\n fail,\n warn: findings.length - fail,\n };\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,6 +8,9 @@ interface DoctorOptions extends CliOptions {
|
|
|
8
8
|
/** `--dedup`: scan host-context + `.noir/` docs for semantic near-duplicates.
|
|
9
9
|
* Opt-in (loads the local embedder) so a default `noir doctor` stays fast. */
|
|
10
10
|
dedup?: boolean;
|
|
11
|
+
/** `--fix`: re-assert owner-only permissions on `.noir/.env` and the store
|
|
12
|
+
* DB + directory before the checks read them, and report what was healed. */
|
|
13
|
+
fix?: boolean;
|
|
11
14
|
}
|
|
12
15
|
/**
|
|
13
16
|
* `noir doctor`: run all checks, render, and exit.
|
|
@@ -78,6 +81,7 @@ declare function init(root: string, opts: InitOptions): Promise<InitResult | und
|
|
|
78
81
|
|
|
79
82
|
declare function serve(opts: {
|
|
80
83
|
stdio: boolean;
|
|
84
|
+
workspace?: string;
|
|
81
85
|
}): Promise<void>;
|
|
82
86
|
|
|
83
87
|
export { type InitOptions, doctor, init, serve };
|