@noir-ai/cli 1.17.0-beta.1 → 1.18.0-beta.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/dist/bin.js +89 -27
- package/dist/bin.js.map +1 -1
- package/dist/hygiene-scan.js +10 -1
- package/dist/hygiene-scan.js.map +1 -1
- package/dist/index.js +123 -61
- package/dist/index.js.map +1 -1
- package/dist/tui/{chunk-NZMM6YXE.js → chunk-A5XEQWRN.js} +82 -26
- package/dist/tui/chunk-A5XEQWRN.js.map +1 -0
- package/dist/tui/{create-4XGNRRP7.js → create-GUXMIKZB.js} +2 -2
- package/dist/tui/index.js +1 -1
- package/dist/tui/{sync-Q3KXM5RQ.js → sync-PXMB4SJT.js} +2 -2
- package/package.json +11 -11
- package/dist/tui/chunk-NZMM6YXE.js.map +0 -1
- /package/dist/tui/{create-4XGNRRP7.js.map → create-GUXMIKZB.js.map} +0 -0
- /package/dist/tui/{sync-Q3KXM5RQ.js.map → sync-PXMB4SJT.js.map} +0 -0
package/dist/hygiene-scan.js
CHANGED
|
@@ -15,6 +15,14 @@ var HYGIENE_MARKDOWN_EXTENSIONS = /\.(?:md|mdx)$/i;
|
|
|
15
15
|
var HYGIENE_SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "coverage"]);
|
|
16
16
|
var HYGIENE_EXCLUDED_DIRS = /* @__PURE__ */ new Set(["docs/internal", "docs/decisions", "docs/roadmap"]);
|
|
17
17
|
var HYGIENE_EXCLUDED_FILES = /* @__PURE__ */ new Set(["CHANGELOG.md"]);
|
|
18
|
+
var HYGIENE_EXCLUDED_PATHS = /* @__PURE__ */ new Set([
|
|
19
|
+
"docs/README.md",
|
|
20
|
+
"docs/reference/cli.md",
|
|
21
|
+
"docs/reference/config.md",
|
|
22
|
+
"docs/reference/mcp-tools.md",
|
|
23
|
+
"docs/reference/packages.md",
|
|
24
|
+
"docs/reference/skills.md"
|
|
25
|
+
]);
|
|
18
26
|
function readDirEntries(dir) {
|
|
19
27
|
try {
|
|
20
28
|
return readdirSync(dir, { withFileTypes: true });
|
|
@@ -45,7 +53,8 @@ function hasScannableLayout(root) {
|
|
|
45
53
|
}
|
|
46
54
|
function addHygieneFile(rel, walk) {
|
|
47
55
|
const name = rel.slice(rel.lastIndexOf("/") + 1);
|
|
48
|
-
if (name.startsWith(".") || HYGIENE_EXCLUDED_FILES.has(name))
|
|
56
|
+
if (name.startsWith(".") || HYGIENE_EXCLUDED_FILES.has(name) || HYGIENE_EXCLUDED_PATHS.has(rel))
|
|
57
|
+
return;
|
|
49
58
|
const kind = hygieneKindOf(rel);
|
|
50
59
|
if (kind === null) return;
|
|
51
60
|
if (walk.files.length >= HYGIENE_MAX_FILES) {
|
package/dist/hygiene-scan.js.map
CHANGED
|
@@ -1 +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 {\n checkHygiene,\n type HygieneKind,\n type HygieneTier,\n hasHygieneExemption,\n humanizerFindings,\n slopFindings,\n} 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 if (file.kind === 'markdown' && !hasHygieneExemption(text, file.kind)) {\n for (const found of [...slopFindings(text), ...humanizerFindings(text)]) {\n findings.push({ path: file.path, line: found.line, id: found.id, tier: found.tier });\n }\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":[]}
|
|
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 {\n checkHygiene,\n type HygieneKind,\n type HygieneTier,\n hasHygieneExemption,\n humanizerFindings,\n slopFindings,\n} 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/** Generated documentation, excluded by path: the reference pages and the\n * `docs/README.md` index are regenerated from schemas, skill descriptions and\n * tool definitions, so their prose is not the hand-written text the rules are\n * written for. A rule finding there reflects the source, not a slop slip, and\n * an edit would be overwritten by the next `pnpm docs:generate`. Root-relative\n * POSIX paths. */\nconst HYGIENE_EXCLUDED_PATHS = new Set([\n 'docs/README.md',\n 'docs/reference/cli.md',\n 'docs/reference/config.md',\n 'docs/reference/mcp-tools.md',\n 'docs/reference/packages.md',\n 'docs/reference/skills.md',\n]);\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) || HYGIENE_EXCLUDED_PATHS.has(rel))\n 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 if (file.kind === 'markdown' && !hasHygieneExemption(text, file.kind)) {\n for (const found of [...slopFindings(text), ...humanizerFindings(text)]) {\n findings.push({ path: file.path, line: found.line, id: found.id, tier: found.tier });\n }\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.js
CHANGED
|
@@ -3103,6 +3103,7 @@ function collectLeaves(cmd, path, out) {
|
|
|
3103
3103
|
const isLeaf = hasAction(cmd) && children.length === 0;
|
|
3104
3104
|
if (isLeaf) {
|
|
3105
3105
|
const argv = path;
|
|
3106
|
+
if (INTERNAL_LEAF_COMMANDS.has(argv.join(" "))) return;
|
|
3106
3107
|
const id = argv.join(" ");
|
|
3107
3108
|
const description = cmd.description() || argv.join(" ");
|
|
3108
3109
|
out.push({
|
|
@@ -3126,7 +3127,7 @@ function buildPaletteCommands(program2) {
|
|
|
3126
3127
|
collectLeaves(program2, [], out);
|
|
3127
3128
|
return out;
|
|
3128
3129
|
}
|
|
3129
|
-
var DESTRUCTIVE_PREFIXES;
|
|
3130
|
+
var DESTRUCTIVE_PREFIXES, INTERNAL_LEAF_COMMANDS;
|
|
3130
3131
|
var init_registry = __esm({
|
|
3131
3132
|
"src/tui/commands/registry.ts"() {
|
|
3132
3133
|
"use strict";
|
|
@@ -3148,6 +3149,7 @@ var init_registry = __esm({
|
|
|
3148
3149
|
["update"],
|
|
3149
3150
|
["run"]
|
|
3150
3151
|
];
|
|
3152
|
+
INTERNAL_LEAF_COMMANDS = /* @__PURE__ */ new Set(["hook"]);
|
|
3151
3153
|
}
|
|
3152
3154
|
});
|
|
3153
3155
|
|
|
@@ -3607,8 +3609,55 @@ var init_home = __esm({
|
|
|
3607
3609
|
}
|
|
3608
3610
|
});
|
|
3609
3611
|
|
|
3612
|
+
// src/commands/hook.ts
|
|
3613
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
3614
|
+
import { join as join3 } from "path";
|
|
3615
|
+
function hook() {
|
|
3616
|
+
let eventName = "SessionStart";
|
|
3617
|
+
let root = process.cwd();
|
|
3618
|
+
try {
|
|
3619
|
+
const input = JSON.parse(readFileSync2(0, "utf8"));
|
|
3620
|
+
if (typeof input.hook_event_name === "string" && input.hook_event_name !== "") {
|
|
3621
|
+
eventName = input.hook_event_name;
|
|
3622
|
+
}
|
|
3623
|
+
if (typeof input.cwd === "string" && input.cwd !== "") root = input.cwd;
|
|
3624
|
+
} catch {
|
|
3625
|
+
}
|
|
3626
|
+
process.stdout.write(JSON.stringify(hookOutput(eventName, root)));
|
|
3627
|
+
}
|
|
3628
|
+
function hookOutput(eventName, root) {
|
|
3629
|
+
let routerPath = null;
|
|
3630
|
+
let rulesPath = null;
|
|
3631
|
+
for (let dir = root; ; dir = join3(dir, "..")) {
|
|
3632
|
+
const router = join3(dir, ".noir", "router.md");
|
|
3633
|
+
const rules = join3(dir, ".noir", "rules", "anti-slop.md");
|
|
3634
|
+
if (existsSync2(router) || existsSync2(rules)) {
|
|
3635
|
+
routerPath = router;
|
|
3636
|
+
rulesPath = rules;
|
|
3637
|
+
break;
|
|
3638
|
+
}
|
|
3639
|
+
const parent = join3(dir, "..");
|
|
3640
|
+
if (parent === dir) break;
|
|
3641
|
+
}
|
|
3642
|
+
const parts = [];
|
|
3643
|
+
for (const path of [routerPath, rulesPath]) {
|
|
3644
|
+
if (path === null) continue;
|
|
3645
|
+
try {
|
|
3646
|
+
const text = readFileSync2(path, "utf8").trim();
|
|
3647
|
+
if (text !== "") parts.push(text);
|
|
3648
|
+
} catch {
|
|
3649
|
+
}
|
|
3650
|
+
}
|
|
3651
|
+
return parts.length === 0 ? { hookSpecificOutput: { hookEventName: eventName } } : { hookSpecificOutput: { hookEventName: eventName, additionalContext: parts.join("\n\n") } };
|
|
3652
|
+
}
|
|
3653
|
+
var init_hook = __esm({
|
|
3654
|
+
"src/commands/hook.ts"() {
|
|
3655
|
+
"use strict";
|
|
3656
|
+
}
|
|
3657
|
+
});
|
|
3658
|
+
|
|
3610
3659
|
// src/commands/memory.ts
|
|
3611
|
-
import { readFileSync as
|
|
3660
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
3612
3661
|
import { loadProjectInfo as loadProjectInfo11 } from "@noir-ai/core";
|
|
3613
3662
|
function resolveWorkspace() {
|
|
3614
3663
|
const routing = workspaceRoutingTarget(process.cwd());
|
|
@@ -3826,14 +3875,14 @@ async function resolveCaptureContent(opts) {
|
|
|
3826
3875
|
if (typeof opts.content === "string" && opts.content.length > 0) return opts.content;
|
|
3827
3876
|
if (typeof opts.file === "string" && opts.file.length > 0) {
|
|
3828
3877
|
try {
|
|
3829
|
-
return
|
|
3878
|
+
return readFileSync3(opts.file, "utf8");
|
|
3830
3879
|
} catch {
|
|
3831
3880
|
fail(EXIT.USAGE, `memory capture could not read --file ${opts.file}`, opts);
|
|
3832
3881
|
}
|
|
3833
3882
|
}
|
|
3834
3883
|
if (!process.stdin.isTTY) {
|
|
3835
3884
|
try {
|
|
3836
|
-
return
|
|
3885
|
+
return readFileSync3(0, "utf8");
|
|
3837
3886
|
} catch {
|
|
3838
3887
|
fail(
|
|
3839
3888
|
EXIT.USAGE,
|
|
@@ -5216,8 +5265,8 @@ var init_run_status = __esm({
|
|
|
5216
5265
|
});
|
|
5217
5266
|
|
|
5218
5267
|
// src/commands/run.ts
|
|
5219
|
-
import { existsSync as
|
|
5220
|
-
import { join as
|
|
5268
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
|
|
5269
|
+
import { join as join4 } from "path";
|
|
5221
5270
|
import { SUPPORTED_HOSTS } from "@noir-ai/adapters";
|
|
5222
5271
|
import { isDeniedEnvKey as isDeniedEnvKey2, loadNoirEnv as loadNoirEnv2, NOIR_DIR as NOIR_DIR2, parseConfig, paths } from "@noir-ai/core";
|
|
5223
5272
|
function credentialNote(env2, sources) {
|
|
@@ -5277,7 +5326,7 @@ async function runOnce(prompt, opts, resumeSessionId) {
|
|
|
5277
5326
|
const customBinary = opts.command ?? profile.binary;
|
|
5278
5327
|
const extraArgs = resumeSessionId === void 0 ? profile.args : [...profile.args ?? [], "--resume", resumeSessionId];
|
|
5279
5328
|
const env2 = profile.env ? mergeEnv(process.env, profile.env) : void 0;
|
|
5280
|
-
if (!
|
|
5329
|
+
if (!existsSync3(join4(root, NOIR_DIR2, ".env"))) {
|
|
5281
5330
|
log(
|
|
5282
5331
|
`This project has no ${NOIR_DIR2}/.env \u2014 that is where Noir reads project credentials and run config from. Run \`noir init\` to scaffold one (\`noir run\` works without it).`,
|
|
5283
5332
|
opts
|
|
@@ -5440,7 +5489,7 @@ function safeTranscript(host, lines) {
|
|
|
5440
5489
|
function writeTranscript(host, lines, root = process.cwd()) {
|
|
5441
5490
|
const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
5442
5491
|
const dir = paths.transcriptsDir(root);
|
|
5443
|
-
const file =
|
|
5492
|
+
const file = join4(dir, `${host}-${ts}.jsonl`);
|
|
5444
5493
|
try {
|
|
5445
5494
|
mkdirSync3(dir, { recursive: true, mode: 448 });
|
|
5446
5495
|
writeFileSync4(file, `${lines.join("\n")}${lines.length > 0 ? "\n" : ""}`, { mode: 384 });
|
|
@@ -5576,8 +5625,8 @@ var init_conflict = __esm({
|
|
|
5576
5625
|
});
|
|
5577
5626
|
|
|
5578
5627
|
// src/dedup-write.ts
|
|
5579
|
-
import { mkdirSync as mkdirSync4, readFileSync as
|
|
5580
|
-
import { join as
|
|
5628
|
+
import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, rmSync, writeFileSync as writeFileSync5 } from "fs";
|
|
5629
|
+
import { join as join5 } from "path";
|
|
5581
5630
|
import { sha256Hex as sha256Hex2, sha256Hex12 } from "@noir-ai/core";
|
|
5582
5631
|
async function checkWritePathDedup(root, res, opts) {
|
|
5583
5632
|
const result = { found: [], conflicts: [] };
|
|
@@ -5588,10 +5637,10 @@ async function checkWritePathDedup(root, res, opts) {
|
|
|
5588
5637
|
if (embed === void 0) return result;
|
|
5589
5638
|
const cachedEmbed = makeCachedEmbed(cache, embed);
|
|
5590
5639
|
for (const proposedRel of writtenHostFiles) {
|
|
5591
|
-
const proposedAbs =
|
|
5640
|
+
const proposedAbs = join5(root, proposedRel);
|
|
5592
5641
|
let proposedText;
|
|
5593
5642
|
try {
|
|
5594
|
-
proposedText =
|
|
5643
|
+
proposedText = readFileSync4(proposedAbs, "utf8");
|
|
5595
5644
|
} catch {
|
|
5596
5645
|
continue;
|
|
5597
5646
|
}
|
|
@@ -5635,7 +5684,7 @@ async function checkWritePathDedup(root, res, opts) {
|
|
|
5635
5684
|
`
|
|
5636
5685
|
);
|
|
5637
5686
|
}
|
|
5638
|
-
const matchedText = safeRead(
|
|
5687
|
+
const matchedText = safeRead(join5(root, matchedRel));
|
|
5639
5688
|
result.conflicts.push({
|
|
5640
5689
|
path: proposedRel,
|
|
5641
5690
|
mode: "artifact",
|
|
@@ -5719,21 +5768,21 @@ async function promptDedupAction(root, proposedRel, matchedRel, proposedText, si
|
|
|
5719
5768
|
if (clack.isCancel(choice)) return "preserve";
|
|
5720
5769
|
if (choice === "skip") {
|
|
5721
5770
|
try {
|
|
5722
|
-
rmSync(
|
|
5771
|
+
rmSync(join5(root, proposedRel), { force: true });
|
|
5723
5772
|
} catch {
|
|
5724
5773
|
}
|
|
5725
5774
|
return "preserve";
|
|
5726
5775
|
} else if (choice === "replace") {
|
|
5727
5776
|
try {
|
|
5728
|
-
writeFileSync5(
|
|
5777
|
+
writeFileSync5(join5(root, matchedRel), proposedText, "utf8");
|
|
5729
5778
|
} catch {
|
|
5730
5779
|
}
|
|
5731
5780
|
return "replace";
|
|
5732
5781
|
} else if (choice === "mirror") {
|
|
5733
5782
|
try {
|
|
5734
|
-
const existing =
|
|
5783
|
+
const existing = readFileSync4(join5(root, matchedRel), "utf8");
|
|
5735
5784
|
writeFileSync5(
|
|
5736
|
-
|
|
5785
|
+
join5(root, matchedRel),
|
|
5737
5786
|
`${existing}
|
|
5738
5787
|
|
|
5739
5788
|
<!-- mirrored from ${proposedRel} -->
|
|
@@ -5751,11 +5800,11 @@ function emptyCache() {
|
|
|
5751
5800
|
return { version: DEDUP_CACHE_VERSION, model: "", entries: {} };
|
|
5752
5801
|
}
|
|
5753
5802
|
function cachePath(root) {
|
|
5754
|
-
return
|
|
5803
|
+
return join5(root, ".noir", "dedup-cache.json");
|
|
5755
5804
|
}
|
|
5756
5805
|
function loadCache(root) {
|
|
5757
5806
|
try {
|
|
5758
|
-
const raw =
|
|
5807
|
+
const raw = readFileSync4(cachePath(root), "utf8");
|
|
5759
5808
|
const parsed = JSON.parse(raw);
|
|
5760
5809
|
if (parsed.version !== DEDUP_CACHE_VERSION) return emptyCache();
|
|
5761
5810
|
if (typeof parsed.entries !== "object" || parsed.entries === null) return emptyCache();
|
|
@@ -5770,7 +5819,7 @@ function loadCache(root) {
|
|
|
5770
5819
|
}
|
|
5771
5820
|
function saveCache(root, cache) {
|
|
5772
5821
|
try {
|
|
5773
|
-
mkdirSync4(
|
|
5822
|
+
mkdirSync4(join5(root, ".noir"), { recursive: true });
|
|
5774
5823
|
writeFileSync5(cachePath(root), JSON.stringify(cache), "utf8");
|
|
5775
5824
|
} catch {
|
|
5776
5825
|
}
|
|
@@ -5787,7 +5836,7 @@ function makeCachedEmbed(cache, embed) {
|
|
|
5787
5836
|
}
|
|
5788
5837
|
function safeRead(abs) {
|
|
5789
5838
|
try {
|
|
5790
|
-
return
|
|
5839
|
+
return readFileSync4(abs, "utf8");
|
|
5791
5840
|
} catch {
|
|
5792
5841
|
return void 0;
|
|
5793
5842
|
}
|
|
@@ -5825,7 +5874,7 @@ var init_dedup_write = __esm({
|
|
|
5825
5874
|
});
|
|
5826
5875
|
|
|
5827
5876
|
// src/init.ts
|
|
5828
|
-
import { existsSync as
|
|
5877
|
+
import { existsSync as existsSync4 } from "fs";
|
|
5829
5878
|
import { resolveAdapter as resolveAdapter2 } from "@noir-ai/adapters";
|
|
5830
5879
|
import { loadProjectInfo as loadProjectInfo13, paths as paths2 } from "@noir-ai/core";
|
|
5831
5880
|
import { scaffold } from "@noir-ai/create";
|
|
@@ -5890,7 +5939,7 @@ async function init(root, opts) {
|
|
|
5890
5939
|
}
|
|
5891
5940
|
function resolveInitHost(root, opts) {
|
|
5892
5941
|
if (opts.host !== void 0) return opts.host;
|
|
5893
|
-
if (!
|
|
5942
|
+
if (!existsSync4(paths2.config(root))) return "claude";
|
|
5894
5943
|
try {
|
|
5895
5944
|
return loadProjectInfo13(root).config.host;
|
|
5896
5945
|
} catch {
|
|
@@ -6362,7 +6411,7 @@ var sync_exports = {};
|
|
|
6362
6411
|
__export(sync_exports, {
|
|
6363
6412
|
sync: () => sync
|
|
6364
6413
|
});
|
|
6365
|
-
import { existsSync as
|
|
6414
|
+
import { existsSync as existsSync5 } from "fs";
|
|
6366
6415
|
import { resolveAdapter as resolveAdapter5 } from "@noir-ai/adapters";
|
|
6367
6416
|
import { loadProjectInfo as loadProjectInfo17, paths as paths3 } from "@noir-ai/core";
|
|
6368
6417
|
import { scaffold as scaffold3 } from "@noir-ai/create";
|
|
@@ -6435,7 +6484,7 @@ async function sync(root, opts = {}) {
|
|
|
6435
6484
|
}
|
|
6436
6485
|
function resolveSyncHost(root, opts) {
|
|
6437
6486
|
if (opts.host !== void 0) return opts.host;
|
|
6438
|
-
if (!
|
|
6487
|
+
if (!existsSync5(paths3.config(root))) return "claude";
|
|
6439
6488
|
try {
|
|
6440
6489
|
return loadProjectInfo17(root).config.host;
|
|
6441
6490
|
} catch {
|
|
@@ -6794,6 +6843,9 @@ function createProgram() {
|
|
|
6794
6843
|
program2.command("env").description("which configuration is in effect and where each value comes from").action(async (...args) => {
|
|
6795
6844
|
await env(toCliOptions(actionGlobals(args)));
|
|
6796
6845
|
});
|
|
6846
|
+
program2.command("hook", { hidden: true }).description("host hook runner (SessionStart / SubagentStart context injection)").action(async () => {
|
|
6847
|
+
await hook();
|
|
6848
|
+
});
|
|
6797
6849
|
const contextGrp = program2.command("context").description("context engine");
|
|
6798
6850
|
contextGrp.command("search").description("hybrid search over the indexed context").argument("<query>", "search query").option("--limit <n>", "max results", "10").action(async (...args) => {
|
|
6799
6851
|
const cmd = trailingCmd(args);
|
|
@@ -7182,6 +7234,7 @@ var init_bin = __esm({
|
|
|
7182
7234
|
init_env();
|
|
7183
7235
|
init_handoff();
|
|
7184
7236
|
init_home();
|
|
7237
|
+
init_hook();
|
|
7185
7238
|
init_memory();
|
|
7186
7239
|
init_release();
|
|
7187
7240
|
init_run();
|
|
@@ -7626,8 +7679,8 @@ var init_daemon_client = __esm({
|
|
|
7626
7679
|
});
|
|
7627
7680
|
|
|
7628
7681
|
// src/hygiene-scan.ts
|
|
7629
|
-
import { readdirSync as readdirSync2, readFileSync as
|
|
7630
|
-
import { join as
|
|
7682
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync5, statSync } from "fs";
|
|
7683
|
+
import { join as join6 } from "path";
|
|
7631
7684
|
import {
|
|
7632
7685
|
checkHygiene,
|
|
7633
7686
|
hasHygieneExemption,
|
|
@@ -7654,17 +7707,18 @@ function hygieneKindOf(path) {
|
|
|
7654
7707
|
return null;
|
|
7655
7708
|
}
|
|
7656
7709
|
function hasScannableLayout(root) {
|
|
7657
|
-
if (isDirectory(
|
|
7658
|
-
for (const entry of readDirEntries(
|
|
7710
|
+
if (isDirectory(join6(root, "docs")) || isDirectory(join6(root, "scripts"))) return true;
|
|
7711
|
+
for (const entry of readDirEntries(join6(root, "packages"))) {
|
|
7659
7712
|
if (!entry.isDirectory()) continue;
|
|
7660
|
-
if (isDirectory(
|
|
7661
|
-
if (isDirectory(
|
|
7713
|
+
if (isDirectory(join6(root, "packages", entry.name, "src"))) return true;
|
|
7714
|
+
if (isDirectory(join6(root, "packages", entry.name, "test"))) return true;
|
|
7662
7715
|
}
|
|
7663
7716
|
return false;
|
|
7664
7717
|
}
|
|
7665
7718
|
function addHygieneFile(rel, walk) {
|
|
7666
7719
|
const name = rel.slice(rel.lastIndexOf("/") + 1);
|
|
7667
|
-
if (name.startsWith(".") || HYGIENE_EXCLUDED_FILES.has(name))
|
|
7720
|
+
if (name.startsWith(".") || HYGIENE_EXCLUDED_FILES.has(name) || HYGIENE_EXCLUDED_PATHS.has(rel))
|
|
7721
|
+
return;
|
|
7668
7722
|
const kind = hygieneKindOf(rel);
|
|
7669
7723
|
if (kind === null) return;
|
|
7670
7724
|
if (walk.files.length >= HYGIENE_MAX_FILES) {
|
|
@@ -7675,7 +7729,7 @@ function addHygieneFile(rel, walk) {
|
|
|
7675
7729
|
}
|
|
7676
7730
|
function walkScannable(root, relDir, skip, walk) {
|
|
7677
7731
|
if (walk.truncated) return;
|
|
7678
|
-
for (const entry of readDirEntries(
|
|
7732
|
+
for (const entry of readDirEntries(join6(root, relDir))) {
|
|
7679
7733
|
if (walk.truncated) return;
|
|
7680
7734
|
const rel = `${relDir}/${entry.name}`;
|
|
7681
7735
|
if (entry.isDirectory()) {
|
|
@@ -7695,7 +7749,7 @@ function collectHygieneFiles(root) {
|
|
|
7695
7749
|
}
|
|
7696
7750
|
walkScannable(root, "docs", HYGIENE_EXCLUDED_DIRS, walk);
|
|
7697
7751
|
walkScannable(root, ".claude/skills", NO_SKIP_DIRS, walk);
|
|
7698
|
-
for (const entry of readDirEntries(
|
|
7752
|
+
for (const entry of readDirEntries(join6(root, "packages"))) {
|
|
7699
7753
|
if (!entry.isDirectory()) continue;
|
|
7700
7754
|
walkScannable(root, `packages/${entry.name}/src`, NO_SKIP_DIRS, walk);
|
|
7701
7755
|
walkScannable(root, `packages/${entry.name}/test`, NO_SKIP_DIRS, walk);
|
|
@@ -7748,13 +7802,13 @@ function scanOutputHygiene(root) {
|
|
|
7748
7802
|
let scanned = 0;
|
|
7749
7803
|
let skipped = 0;
|
|
7750
7804
|
for (const file of walk.files) {
|
|
7751
|
-
const abs =
|
|
7805
|
+
const abs = join6(root, file.path);
|
|
7752
7806
|
try {
|
|
7753
7807
|
if (statSync(abs).size > HYGIENE_MAX_FILE_BYTES) {
|
|
7754
7808
|
skipped++;
|
|
7755
7809
|
continue;
|
|
7756
7810
|
}
|
|
7757
|
-
const text =
|
|
7811
|
+
const text = readFileSync5(abs, "utf8");
|
|
7758
7812
|
scanned++;
|
|
7759
7813
|
for (const found of checkHygiene(text, file.kind)) {
|
|
7760
7814
|
findings.push({ path: file.path, line: found.line, id: found.id, tier: found.tier });
|
|
@@ -7782,7 +7836,7 @@ function scanOutputHygiene(root) {
|
|
|
7782
7836
|
warn: findings.length - fail2
|
|
7783
7837
|
};
|
|
7784
7838
|
}
|
|
7785
|
-
var HYGIENE_FINDING_CAP, HYGIENE_MAX_FILE_BYTES, HYGIENE_MAX_FILES, HYGIENE_CODE_EXTENSIONS, HYGIENE_MARKDOWN_EXTENSIONS, HYGIENE_SKIP_DIRS, HYGIENE_EXCLUDED_DIRS, HYGIENE_EXCLUDED_FILES, NO_SKIP_DIRS;
|
|
7839
|
+
var HYGIENE_FINDING_CAP, HYGIENE_MAX_FILE_BYTES, HYGIENE_MAX_FILES, HYGIENE_CODE_EXTENSIONS, HYGIENE_MARKDOWN_EXTENSIONS, HYGIENE_SKIP_DIRS, HYGIENE_EXCLUDED_DIRS, HYGIENE_EXCLUDED_FILES, HYGIENE_EXCLUDED_PATHS, NO_SKIP_DIRS;
|
|
7786
7840
|
var init_hygiene_scan = __esm({
|
|
7787
7841
|
"src/hygiene-scan.ts"() {
|
|
7788
7842
|
"use strict";
|
|
@@ -7794,14 +7848,22 @@ var init_hygiene_scan = __esm({
|
|
|
7794
7848
|
HYGIENE_SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", "dist", "coverage"]);
|
|
7795
7849
|
HYGIENE_EXCLUDED_DIRS = /* @__PURE__ */ new Set(["docs/internal", "docs/decisions", "docs/roadmap"]);
|
|
7796
7850
|
HYGIENE_EXCLUDED_FILES = /* @__PURE__ */ new Set(["CHANGELOG.md"]);
|
|
7851
|
+
HYGIENE_EXCLUDED_PATHS = /* @__PURE__ */ new Set([
|
|
7852
|
+
"docs/README.md",
|
|
7853
|
+
"docs/reference/cli.md",
|
|
7854
|
+
"docs/reference/config.md",
|
|
7855
|
+
"docs/reference/mcp-tools.md",
|
|
7856
|
+
"docs/reference/packages.md",
|
|
7857
|
+
"docs/reference/skills.md"
|
|
7858
|
+
]);
|
|
7797
7859
|
NO_SKIP_DIRS = /* @__PURE__ */ new Set();
|
|
7798
7860
|
}
|
|
7799
7861
|
});
|
|
7800
7862
|
|
|
7801
7863
|
// src/commands/doctor.ts
|
|
7802
7864
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
7803
|
-
import { existsSync as
|
|
7804
|
-
import { dirname, join as
|
|
7865
|
+
import { existsSync as existsSync6, readdirSync as readdirSync3, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
|
|
7866
|
+
import { dirname, join as join7, relative } from "path";
|
|
7805
7867
|
import { fileURLToPath } from "url";
|
|
7806
7868
|
import { mcpConfigPathFor as mcpConfigPathFor2, resolveAdapter as resolveAdapter6 } from "@noir-ai/adapters";
|
|
7807
7869
|
import {
|
|
@@ -7860,7 +7922,7 @@ async function checkRuntime(checks) {
|
|
|
7860
7922
|
}
|
|
7861
7923
|
function checkConfig(root) {
|
|
7862
7924
|
const configPath = paths4.config(root);
|
|
7863
|
-
if (!
|
|
7925
|
+
if (!existsSync6(configPath)) {
|
|
7864
7926
|
return {
|
|
7865
7927
|
project: void 0,
|
|
7866
7928
|
result: {
|
|
@@ -7960,7 +8022,7 @@ async function checkStore(checks, project, root) {
|
|
|
7960
8022
|
return;
|
|
7961
8023
|
}
|
|
7962
8024
|
const dbPath = paths4.storeDb(root, project.id);
|
|
7963
|
-
if (!
|
|
8025
|
+
if (!existsSync6(dbPath)) {
|
|
7964
8026
|
checks.push({
|
|
7965
8027
|
name: "store",
|
|
7966
8028
|
status: "warn",
|
|
@@ -7999,7 +8061,7 @@ function describeEnvMode(cause, mode) {
|
|
|
7999
8061
|
return cause === "legacy" ? `${lead} Nothing has changed its mode since the file was written, so the file predates the owner-only contract (an older Noir seeded it, a clone delivered it, it was created by hand, or it was saved by a tool that replaces the file) \u2014 this is not a change you made.` : `${lead} Its mode was changed after the file was written (a \`chmod\`, a \`chown\`, or a restore that re-applied metadata), so no older Noir left it this way.`;
|
|
8000
8062
|
}
|
|
8001
8063
|
function checkNoirEnv(checks, root, loaded) {
|
|
8002
|
-
const path =
|
|
8064
|
+
const path = join7(paths4.noirDir(root), ".env");
|
|
8003
8065
|
let st;
|
|
8004
8066
|
try {
|
|
8005
8067
|
st = statSync2(path);
|
|
@@ -8118,10 +8180,10 @@ function checkScaffoldVersion(checks, root) {
|
|
|
8118
8180
|
return { onDisk, current, drift };
|
|
8119
8181
|
}
|
|
8120
8182
|
function checkSeedDrift(checks, root) {
|
|
8121
|
-
const seedPath =
|
|
8183
|
+
const seedPath = join7(root, ".noir", ".env.example");
|
|
8122
8184
|
let bytes;
|
|
8123
8185
|
try {
|
|
8124
|
-
bytes =
|
|
8186
|
+
bytes = readFileSync6(seedPath, "utf8");
|
|
8125
8187
|
} catch {
|
|
8126
8188
|
return;
|
|
8127
8189
|
}
|
|
@@ -8148,7 +8210,7 @@ function checkRulesMdBudget(checks, root, project) {
|
|
|
8148
8210
|
return null;
|
|
8149
8211
|
}
|
|
8150
8212
|
const rulesPath = paths4.rulesMd(root);
|
|
8151
|
-
if (!
|
|
8213
|
+
if (!existsSync6(rulesPath)) {
|
|
8152
8214
|
checks.push({
|
|
8153
8215
|
name: "rules budget",
|
|
8154
8216
|
status: "ok",
|
|
@@ -8158,7 +8220,7 @@ function checkRulesMdBudget(checks, root, project) {
|
|
|
8158
8220
|
}
|
|
8159
8221
|
let content;
|
|
8160
8222
|
try {
|
|
8161
|
-
content =
|
|
8223
|
+
content = readFileSync6(rulesPath, "utf8");
|
|
8162
8224
|
} catch (e) {
|
|
8163
8225
|
checks.push({
|
|
8164
8226
|
name: "rules budget",
|
|
@@ -8212,7 +8274,7 @@ function checkHostArtifacts(checks, root, project) {
|
|
|
8212
8274
|
if (host === "claude") expected.push("CLAUDE.md");
|
|
8213
8275
|
else if (host === "gemini") expected.push("GEMINI.md");
|
|
8214
8276
|
expected.push(relOr(mcpConfigPathFor2(host, root), root));
|
|
8215
|
-
const missing = expected.filter((p) => !
|
|
8277
|
+
const missing = expected.filter((p) => !existsSync6(joinRoot(root, p)));
|
|
8216
8278
|
const status2 = missing.length === 0 ? "ok" : "warn";
|
|
8217
8279
|
const detail = missing.length === 0 ? `host=${host} \xB7 all ${expected.length} primary artifacts present` : `host=${host} \xB7 missing ${missing.join(", ")} (run \`noir sync\` to restore)`;
|
|
8218
8280
|
checks.push({ name: "host artifacts", status: status2, detail });
|
|
@@ -8229,7 +8291,7 @@ function checkNestedNoir(checks, root) {
|
|
|
8229
8291
|
".noir/.npmignore",
|
|
8230
8292
|
".noir/.prettierignore"
|
|
8231
8293
|
];
|
|
8232
|
-
const found = candidates.filter((rel) =>
|
|
8294
|
+
const found = candidates.filter((rel) => existsSync6(join7(root, rel)));
|
|
8233
8295
|
const detected = found.length > 0;
|
|
8234
8296
|
checks.push({
|
|
8235
8297
|
name: "nested .noir",
|
|
@@ -8251,11 +8313,11 @@ function collectDedupCandidates(root) {
|
|
|
8251
8313
|
const rels = ["CLAUDE.md", "AGENTS.md", "GEMINI.md", ".noir/rules/RULES.md"];
|
|
8252
8314
|
const out = [];
|
|
8253
8315
|
for (const rel of rels) {
|
|
8254
|
-
const abs =
|
|
8255
|
-
if (!
|
|
8316
|
+
const abs = join7(root, rel);
|
|
8317
|
+
if (!existsSync6(abs)) continue;
|
|
8256
8318
|
let text;
|
|
8257
8319
|
try {
|
|
8258
|
-
text =
|
|
8320
|
+
text = readFileSync6(abs, "utf8");
|
|
8259
8321
|
} catch {
|
|
8260
8322
|
continue;
|
|
8261
8323
|
}
|
|
@@ -8312,13 +8374,13 @@ async function checkSemanticDupDoctor(checks, root, project, opts = {}) {
|
|
|
8312
8374
|
function resolveWorkspacePackagesDir() {
|
|
8313
8375
|
let here = dirname(fileURLToPath(import.meta.url));
|
|
8314
8376
|
for (let i = 0; i < 8; i++) {
|
|
8315
|
-
const pkgPath =
|
|
8316
|
-
if (
|
|
8377
|
+
const pkgPath = join7(here, "package.json");
|
|
8378
|
+
if (existsSync6(pkgPath)) {
|
|
8317
8379
|
try {
|
|
8318
|
-
const pkg = JSON.parse(
|
|
8380
|
+
const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
|
|
8319
8381
|
if (pkg.name === "@noir-ai/cli") {
|
|
8320
8382
|
const packagesDir = dirname(here);
|
|
8321
|
-
if (
|
|
8383
|
+
if (existsSync6(join7(dirname(packagesDir), "pnpm-workspace.yaml"))) {
|
|
8322
8384
|
return packagesDir;
|
|
8323
8385
|
}
|
|
8324
8386
|
return null;
|
|
@@ -8376,11 +8438,11 @@ function checkPublish(checks, packagesDir, opts = {}) {
|
|
|
8376
8438
|
let checked = 0;
|
|
8377
8439
|
let cliDir = null;
|
|
8378
8440
|
for (const name of entries) {
|
|
8379
|
-
const pkgPath =
|
|
8380
|
-
if (!
|
|
8441
|
+
const pkgPath = join7(packagesDir, name, "package.json");
|
|
8442
|
+
if (!existsSync6(pkgPath)) continue;
|
|
8381
8443
|
let pkg;
|
|
8382
8444
|
try {
|
|
8383
|
-
pkg = JSON.parse(
|
|
8445
|
+
pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
|
|
8384
8446
|
} catch (e) {
|
|
8385
8447
|
issues.push(`${name}/package.json: unparseable (${msg(e)})`);
|
|
8386
8448
|
checked++;
|
|
@@ -8402,7 +8464,7 @@ function checkPublish(checks, packagesDir, opts = {}) {
|
|
|
8402
8464
|
if (pkg.bin == null || typeof pkg.bin === "object" && Object.keys(pkg.bin).length === 0) {
|
|
8403
8465
|
issues.push(`${name}/package.json: bin missing (cli package must declare a bin)`);
|
|
8404
8466
|
}
|
|
8405
|
-
cliDir =
|
|
8467
|
+
cliDir = join7(packagesDir, name);
|
|
8406
8468
|
}
|
|
8407
8469
|
}
|
|
8408
8470
|
let packNote = "";
|
|
@@ -8484,11 +8546,11 @@ function renderHuman2(payload, opts) {
|
|
|
8484
8546
|
}
|
|
8485
8547
|
function fixOwnerOnlyPermissions(root, project) {
|
|
8486
8548
|
const fixes = [
|
|
8487
|
-
{ path: ".noir/.env", outcome: ensureOwnerOnly(
|
|
8549
|
+
{ path: ".noir/.env", outcome: ensureOwnerOnly(join7(paths4.noirDir(root), ".env")) }
|
|
8488
8550
|
];
|
|
8489
8551
|
if (project) {
|
|
8490
8552
|
const dbRel = `.noir/store/${project.id}.db`;
|
|
8491
|
-
fixes.push({ path: dbRel, outcome: ensureOwnerOnly(
|
|
8553
|
+
fixes.push({ path: dbRel, outcome: ensureOwnerOnly(join7(root, dbRel)) });
|
|
8492
8554
|
fixes.push({ path: ".noir/store", outcome: ensureOwnerOnlyDir(paths4.storeDir(root)) });
|
|
8493
8555
|
}
|
|
8494
8556
|
return fixes;
|