@thanbir-tamim/enterprise-project-intelligence-agent 0.1.0 → 0.2.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +23 -2
  2. package/README.md +302 -46
  3. package/assets/figures/feasibility-scoring-engine.png +0 -0
  4. package/assets/figures/hero-architecture.png +0 -0
  5. package/assets/figures/skills-interaction-map.png +0 -0
  6. package/docs/INSTALLATION.md +6 -6
  7. package/docs/SUPPORTED_FORMATS.md +4 -4
  8. package/examples/synthetic-inventory-system/ai-opportunity-matrix/AI_OPPORTUNITY_MATRIX.md +4 -4
  9. package/examples/synthetic-inventory-system/generated-docs/INVENTORY_SUMMARY.md +17 -0
  10. package/examples/synthetic-inventory-system/generated-docs/evidence-index.json +78 -0
  11. package/examples/synthetic-inventory-system/generated-docs/inventory.json +26 -0
  12. package/examples/synthetic-inventory-system/presentation/DEMO_DECK.md +69 -55
  13. package/examples/synthetic-inventory-system/presentation/demo-deck.json +194 -16
  14. package/package.json +2 -1
  15. package/scripts/cli.mjs +20 -14
  16. package/scripts/lib/skills.mjs +1 -1
  17. package/scripts/release-audit.mjs +73 -70
  18. package/skills/corporate-presentation-generator/SKILL.md +49 -23
  19. package/skills/corporate-presentation-generator/scripts/outline-to-deck-json.mjs +77 -0
  20. package/skills/documentation-quality-auditor/SKILL.md +28 -21
  21. package/skills/documentation-quality-auditor/references/audit-checks.md +15 -8
  22. package/skills/documentation-quality-auditor/scripts/audit-docs.mjs +192 -32
  23. package/skills/engineering-knowledge-transfer/SKILL.md +1 -5
  24. package/skills/enterprise-ai-integration-planner/SKILL.md +13 -15
  25. package/skills/enterprise-ai-integration-planner/references/feasibility-scoring.md +26 -5
  26. package/skills/enterprise-ai-integration-planner/scripts/score-opportunity.mjs +32 -15
  27. package/skills/presentation-compliance-validator/SKILL.md +15 -15
  28. package/skills/presentation-compliance-validator/references/rule-schema.md +67 -10
  29. package/skills/presentation-compliance-validator/scripts/validate-deck.mjs +48 -11
  30. package/skills/project-documentation-discovery/SKILL.md +25 -8
  31. package/skills/project-documentation-discovery/references/format-matrix.md +4 -4
  32. package/skills/project-documentation-discovery/scripts/inventory.mjs +174 -100
  33. package/skills/project-documentation-discovery/scripts/secrets.mjs +99 -0
  34. package/skills/project-knowledge-assistant/SKILL.md +1 -7
  35. package/skills/software-change-impact-analyzer/SKILL.md +15 -11
  36. package/skills/software-reverse-engineering/SKILL.md +14 -10
  37. package/skills/software-reverse-engineering/references/system-map-schema.md +27 -0
  38. package/skills/technical-document-generator/SKILL.md +6 -8
  39. package/skills/technical-document-generator/scripts/render-markdown-to-html.mjs +76 -66
@@ -9,17 +9,46 @@ import { pathToFileURL } from "node:url";
9
9
  * citationsPresent, notes } ], sections: [string] }
10
10
  */
11
11
 
12
+ export const RULE_IDS = ["required-sections", "require-title", "max-bullets", "require-footer", "citation-on-numeric-claim"];
13
+ const KNOWN_KEYS = new Set(["name", "requiredSections", "maxBulletsPerSlide", "requireTitleOnEverySlide", "requireFooterOrPageNumber", "requireCitationOnNumericClaims", "palette", "typography", "blockingRuleIds", "humanReviewOnly", "notes", "source", "derivedRules", "optionalRecommendations"]);
14
+
15
+ /** Validates a rule set against references/rule-schema.md. Returns a list of schema errors (empty = valid). */
16
+ export function validateRules(rules) {
17
+ const errors = [];
18
+ if (!rules || typeof rules !== "object" || Array.isArray(rules)) return ["rule set must be a JSON object"];
19
+ for (const k of Object.keys(rules)) if (!KNOWN_KEYS.has(k)) errors.push(`unknown key "${k}"`);
20
+ const isStrArray = (v) => Array.isArray(v) && v.every((x) => typeof x === "string" && x.trim());
21
+ if ("requiredSections" in rules && !isStrArray(rules.requiredSections)) errors.push("requiredSections must be an array of non-empty strings");
22
+ if ("maxBulletsPerSlide" in rules && !(Number.isInteger(rules.maxBulletsPerSlide) && rules.maxBulletsPerSlide > 0)) errors.push("maxBulletsPerSlide must be a positive integer");
23
+ for (const k of ["requireTitleOnEverySlide", "requireFooterOrPageNumber", "requireCitationOnNumericClaims"]) if (k in rules && typeof rules[k] !== "boolean") errors.push(`${k} must be true or false`);
24
+ for (const k of ["palette", "typography"]) if (k in rules && (typeof rules[k] !== "object" || Array.isArray(rules[k]) || Object.values(rules[k]).some((v) => typeof v !== "string"))) errors.push(`${k} must be an object of string values`);
25
+ if (rules.palette) for (const [k, v] of Object.entries(rules.palette)) if (typeof v === "string" && !/^#[0-9A-Fa-f]{6}$/.test(v)) errors.push(`palette.${k} must be a #RRGGBB colour`);
26
+ if ("blockingRuleIds" in rules) {
27
+ if (!isStrArray(rules.blockingRuleIds)) errors.push("blockingRuleIds must be an array of strings");
28
+ else for (const id of rules.blockingRuleIds) if (!RULE_IDS.includes(id) && !/^(palette|typography)-[A-Za-z]+$/.test(id)) errors.push(`blockingRuleIds contains unknown rule id "${id}" (known: ${RULE_IDS.join(", ")}, palette-<key>, typography-<key>)`);
29
+ }
30
+ if ("humanReviewOnly" in rules && !isStrArray(rules.humanReviewOnly)) errors.push("humanReviewOnly must be an array of strings");
31
+ return errors;
32
+ }
33
+
12
34
  export function validateDeck(deck, rules) {
35
+ const schemaErrors = validateRules(rules);
36
+ if (schemaErrors.length) {
37
+ // SKILL.md: a malformed rule set is reported and validation stops — never guess intent.
38
+ return { pass: false, schemaErrors, findings: [], humanReview: [], blockingFailureCount: 0, stopped: "invalid rule set" };
39
+ }
13
40
  const findings = [];
14
41
  const humanReview = [];
15
42
 
43
+ const deckObj = deck && typeof deck === "object" ? deck : {};
16
44
  const blocking = new Set(rules.blockingRuleIds || []);
17
45
 
18
- // Required sections
46
+ // Required sections (matched case-insensitively against declared sections and slide titles)
19
47
  if (rules.requiredSections) {
20
- const sections = new Set(deck.sections || []);
48
+ const norm = (s) => String(s || "").trim().toLowerCase();
49
+ const sections = new Set([...(deckObj.sections || []), ...(deckObj.slides || []).map((s) => s?.title)].map(norm));
21
50
  for (const s of rules.requiredSections) {
22
- if (!sections.has(s)) {
51
+ if (![...sections].some((x) => x === norm(s) || x.startsWith(norm(s) + ":") || x.startsWith(norm(s) + " —") || x.startsWith(norm(s) + " -"))) {
23
52
  findings.push({
24
53
  ruleId: "required-sections",
25
54
  severity: blocking.has("required-sections") ? "blocking" : "recommendation",
@@ -31,7 +60,8 @@ export function validateDeck(deck, rules) {
31
60
  }
32
61
  }
33
62
 
34
- (deck.slides || []).forEach((slide, idx) => {
63
+ (deckObj.slides || []).forEach((slide, idx) => {
64
+ slide = slide || {};
35
65
  const slideNum = idx + 1;
36
66
 
37
67
  if (rules.requireTitleOnEverySlide && !slide.title) {
@@ -71,10 +101,10 @@ export function validateDeck(deck, rules) {
71
101
  // Palette / typography: only checkable if the deck actually exposes these properties.
72
102
  for (const key of ["palette", "typography"]) {
73
103
  if (rules[key]) {
74
- if (deck[key]) {
104
+ if (deckObj[key]) {
75
105
  for (const [prop, expected] of Object.entries(rules[key])) {
76
- const actual = deck[key][prop];
77
- if (actual && actual !== expected) {
106
+ const actual = deckObj[key][prop];
107
+ if (actual && String(actual).toLowerCase() !== String(expected).toLowerCase()) {
78
108
  findings.push({
79
109
  ruleId: `${key}-${prop}`, severity: blocking.has(`${key}-${prop}`) ? "blocking" : "recommendation",
80
110
  slide: null, finding: `${key}.${prop} is "${actual}", expected "${expected}".`,
@@ -111,10 +141,17 @@ async function main() {
111
141
  console.error("Usage: node validate-deck.mjs <deck.json> <rules.json>");
112
142
  process.exit(1);
113
143
  }
114
- const fs = await import("node:fs");
115
- const deck = JSON.parse(fs.readFileSync(deckPath, "utf8"));
116
- const rules = JSON.parse(fs.readFileSync(rulesPath, "utf8"));
117
- console.log(JSON.stringify(validateDeck(deck, rules), null, 2));
144
+ try {
145
+ const fs = await import("node:fs");
146
+ const deck = JSON.parse(fs.readFileSync(deckPath, "utf8"));
147
+ const rules = JSON.parse(fs.readFileSync(rulesPath, "utf8"));
148
+ const result = validateDeck(deck, rules);
149
+ console.log(JSON.stringify(result, null, 2));
150
+ process.exitCode = result.pass ? 0 : 1; // non-zero on blocking failures or an invalid rule set
151
+ } catch (err) {
152
+ console.error(`validate-deck: ${err.message}`);
153
+ process.exit(1);
154
+ }
118
155
  }
119
156
 
120
157
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
@@ -1,13 +1,6 @@
1
1
  ---
2
2
  name: project-documentation-discovery
3
- description: >-
4
- Inventory and ingest an arbitrary project's documentation corpus (Markdown, HTML, plain text,
5
- DOCX, PPTX, PDF, CSV, JSON, YAML, images/screenshots, source code and config files, plus
6
- Keynote/Pages via their exported formats). Use this skill first, whenever a new project corpus is
7
- supplied, whenever the corpus changes, or whenever another skill needs a source-evidence index. It
8
- builds a document inventory and a source evidence index without loading the whole corpus into the
9
- model's context, and it never claims a format is supported unless extraction was actually verified
10
- for that file.
3
+ description: "Inventory any project documentation/code corpus and build a citable source-evidence index. Extracts Markdown, plain text, HTML (headings/tables), CSV, JSON, YAML (syntax-checked), and source/config files; catalogues DOCX, PPTX, PDF, Keynote, Pages and images as unsupported until converted, instead of pretending to read them. Redacts secret-like values with file/line records. Use first whenever a new or changed corpus is supplied, or when another skill needs an evidence index; it processes files incrementally and never over-claims extraction."
11
4
  metadata:
12
5
  version: 1.0.0
13
6
  partOf: enterprise-project-intelligence-agent
@@ -16,6 +9,7 @@ metadata:
16
9
  - references/extraction-checklist.md
17
10
  scripts:
18
11
  - scripts/inventory.mjs
12
+ - scripts/secrets.mjs
19
13
  ---
20
14
 
21
15
  # project-documentation-discovery
@@ -40,6 +34,8 @@ extracted, what could not, and exactly where every extracted fact came from.
40
34
 
41
35
  - `corpusPath` — path to the folder containing the target project's documentation and/or source code.
42
36
  Treat it as read-only. Never modify, move, or delete files inside it.
37
+ - Optional exclusions — directory/file names or globs (e.g. `drafts`, `**/*.min.js`) passed as
38
+ `--exclude a,b` (taken from the project profile's `excludePaths` when present).
43
39
  - Optional `project-profile.yaml` (see `templates/project-profile.example.yaml` in the repo root) that
44
40
  names the project, states confidentiality level, and lists any paths to exclude.
45
41
  - Optional previous inventory (`inventory.json`) for incremental re-scans.
@@ -76,6 +72,26 @@ extracted, what could not, and exactly where every extracted fact came from.
76
72
  8. **Report a summary**: file counts by status, formats seen, formats requested but unsupported,
77
73
  and the list of files that need visual/manual inspection.
78
74
 
75
+ ## Running the reference script
76
+
77
+ ```bash
78
+ node scripts/inventory.mjs <corpusPath> --out ./.local-knowledge/<project-id> [--exclude drafts,**/*.min.js]
79
+ # or, from the repository root: node scripts/cli.mjs inventory <corpusPath> --out <dir> [--exclude ...]
80
+ ```
81
+
82
+ What it guarantees (each item covered by an automated test):
83
+ - Markdown/text/source/JSON: `extracted-full` (JSON must parse, otherwise `extraction-failed`).
84
+ - YAML: checked by a minimal built-in syntax checker — `extracted-partial` when it passes (values are
85
+ not parsed), `extraction-failed` with line-level reasons when it does not.
86
+ - CSV: RFC-4180 quote-aware parsing; ragged rows downgrade to `extracted-partial`.
87
+ - HTML: always `extracted-partial` (headings/tables only).
88
+ - DOCX/PPTX/PDF/Keynote/Pages/images: `unsupported-format` with a conversion recommendation.
89
+ - Secret-like values (keyed assignments such as `API_KEY=…`, private keys, credentialed URLs,
90
+ high-entropy tokens) are redacted before indexing in every text file, including config and source
91
+ files, and recorded as `evidence_type: "redacted-secret"` entries with `source_location: path#L<n>`.
92
+ Ordinary identifiers (SNAKE_CASE names, hashes, slash-separated words) are not redacted.
93
+ - Outputs contain corpus-relative paths only.
94
+
79
95
  ## Expected outputs
80
96
 
81
97
  - `inventory.json` — one record per file: `{ path, type, title, status, hash, warnings[] }`.
@@ -128,6 +144,7 @@ extracted, what could not, and exactly where every extracted fact came from.
128
144
 
129
145
  - `references/format-matrix.md` — per-format support status and extraction method.
130
146
  - `references/extraction-checklist.md` — step-by-step checklist used during extraction.
147
+ - `scripts/secrets.mjs` — shared secret-likeness detector (also used by the repository release audit).
131
148
  - `scripts/inventory.mjs` — reference implementation: walks a folder and emits `inventory.json`
132
149
  for the formats this toolkit can parse with built-in Node.js tooling (Markdown, text, JSON, YAML,
133
150
  CSV, HTML headings). Formats requiring external libraries (DOCX/PPTX/PDF binary parsing) are
@@ -10,10 +10,10 @@ sync with `scripts/inventory.mjs` and the repo-level `docs/SUPPORTED_FORMATS.md`
10
10
  | Markdown (`.md`) | Native Node.js text read + heading regex | Verified | Full heading hierarchy. |
11
11
  | Plain text (`.txt`) | Native Node.js text read | Verified | No structure beyond paragraphs. |
12
12
  | JSON (`.json`) | `JSON.parse` | Verified | Treated as structured data, not prose. |
13
- | YAML (`.yml`, `.yaml`) | Minimal built-in YAML line scanner | Verified (subset) | Complex anchors/multi-doc YAML should be hand-checked. |
14
- | CSV (`.csv`) | Native split + quote-aware parser | Verified | First row treated as header. |
15
- | HTML (`.html`, `.htm`) | Regex-based heading/table extraction | Verified (subset) | Extracts `h1`-`h6`, `table`; does not render CSS/JS-driven content. |
16
- | Source code / config (`.ts`, `.js`, `.py`, `.java`, `.go`, `.rb`, `.sql`, `.env.example`, etc.) | Treated as text; language-aware parsing is out of scope | Verified (as text) | Used for reverse engineering, not prose extraction. |
13
+ | YAML (`.yml`, `.yaml`) | Minimal built-in syntax checker (indentation, flow brackets, quotes, line shape); top-level keys indexed | Verified (syntax check only) | Passing files are `extracted-partial` (values not parsed); failing files are `extraction-failed` with line numbers. |
14
+ | CSV (`.csv`) | Built-in RFC-4180 quote-aware parser | Verified | First row treated as header; ragged rows → `extracted-partial`. |
15
+ | HTML (`.html`, `.htm`) | Regex-based heading/table detection | Verified (subset) | Always `extracted-partial`: `h1`-`h6` indexed, tables counted; paragraphs and CSS/JS-driven content not captured. |
16
+ | Source code / config (`.ts`, `.js`, `.py`, `.java`, `.go`, `.rb`, `.sql`, `.env*`, `.ini`, `.toml`, etc.) | Treated as text; secret-scanned; one `code` evidence entry per file (`path#L1-Ln`) | Verified (as text) | No language-aware parsing; secrets recorded as `redacted-secret` entries. |
17
17
  | Images / screenshots (`.png`, `.jpg`, `.gif`) | Catalogued by filename/metadata only; no OCR in the reference implementation | Unsupported (cataloguing only) | Flag for manual/visual inspection; do not claim text extraction. |
18
18
  | DOCX | Not implemented in the bundled reference script | Unsupported (requires optional dependency) | Add a DOCX parser (e.g., an XML-zip reader) behind an explicit opt-in dependency before claiming support. |
19
19
  | PPTX | Not implemented in the bundled reference script | Unsupported (requires optional dependency) | Same as DOCX; slide numbers and notes require a PPTX-aware parser. |
@@ -2,66 +2,68 @@
2
2
  /**
3
3
  * Reference documentation-discovery inventory tool.
4
4
  *
5
- * Walks a corpus directory and produces inventory.json + evidence-index.json using only
6
- * built-in Node.js capabilities. Formats that genuinely require an external parser (DOCX,
7
- * PPTX, PDF, Keynote, Pages, images) are honestly reported as "unsupported-format" rather
8
- * than silently skipped or falsely claimed as extracted.
5
+ * Walks a corpus directory and produces inventory.json + evidence-index.json + INVENTORY_SUMMARY.md
6
+ * using only built-in Node.js capabilities. Formats that genuinely require an external parser (DOCX,
7
+ * PPTX, PDF, Keynote, Pages, images) are honestly reported as "unsupported-format" rather than
8
+ * silently skipped or falsely claimed as extracted.
9
9
  *
10
10
  * Usage:
11
- * node inventory.mjs <corpusPath> [--out <dir>] [--exclude <glob>,<glob>...]
11
+ * node inventory.mjs <corpusPath> [--out <dir>] [--exclude <name-or-glob>[,<name-or-glob>...]]
12
+ *
13
+ * --exclude matches a file/directory name or a corpus-relative path; "*" and "**" globs supported.
12
14
  */
13
15
  import { pathToFileURL } from "node:url";
14
- import { readFileSync, statSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
15
- import { join, relative, extname, basename } from "node:path";
16
+ import { readFileSync, statSync, readdirSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
17
+ import { join, relative, extname, basename, resolve, isAbsolute } from "node:path";
16
18
  import { createHash } from "node:crypto";
19
+ import { redactText } from "./secrets.mjs";
17
20
 
18
21
  const DEFAULT_EXCLUDES = [".git", "node_modules", ".DS_Store", "dist", "dist-tests", ".idea"];
19
22
 
20
23
  const TEXT_LIKE_SOURCE_EXT = new Set([
21
24
  ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".java", ".go", ".rb", ".sql",
22
- ".c", ".cpp", ".h", ".hpp", ".cs", ".php", ".sh", ".env", ".ini", ".toml", ".conf",
25
+ ".c", ".cpp", ".h", ".hpp", ".cs", ".php", ".sh", ".env", ".ini", ".toml", ".conf", ".properties",
23
26
  ]);
24
27
 
25
28
  const UNSUPPORTED_BINARY_EXT = new Set([
26
29
  ".docx", ".pptx", ".pdf", ".key", ".pages", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp",
27
30
  ]);
28
31
 
29
- function sha256(buf) {
30
- return createHash("sha256").update(buf).digest("hex");
31
- }
32
+ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
33
+ // GitHub-compatible heading slugs (each space -> "-", punctuation dropped) so anchors resolve on GitHub too.
34
+ const slug = (text) => text.toLowerCase().trim().replace(/[`*_~]/g, (c) => (c === "_" ? "_" : "")).replace(/[^a-z0-9 _-]/g, "").replace(/ /g, "-").slice(0, 120);
32
35
 
33
- function slug(text) {
34
- return text.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").slice(0, 80);
36
+ /* ---------- exclusion ---------- */
37
+ function globToRegExp(glob) {
38
+ const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*");
39
+ return new RegExp(`^${esc}$`);
35
40
  }
36
-
37
- function redactSecrets(text) {
38
- return text
39
- .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[REDACTED-PRIVATE-KEY]")
40
- .replace(/[A-Za-z0-9_\-]{32,}/g, (m) => (/^[A-F0-9]+$/i.test(m) && m.length === 32 ? m : "[REDACTED-TOKEN-LIKE-VALUE]"));
41
+ export function makeExcluder(patterns) {
42
+ const res = patterns.map((p) => p.replace(/\/+$/, "")).filter(Boolean).map((p) => ({ raw: p, re: globToRegExp(p) }));
43
+ return (name, relPath) => res.some(({ raw, re }) => name === raw || relPath === raw || re.test(name) || re.test(relPath) || relPath.startsWith(raw + "/"));
41
44
  }
42
45
 
43
- function walk(dir, excludes, out = []) {
46
+ function walk(root, dir, isExcluded, out = []) {
44
47
  for (const entry of readdirSync(dir)) {
45
- if (excludes.includes(entry)) continue;
46
48
  const p = join(dir, entry);
47
- const st = statSync(p);
48
- if (st.isDirectory()) walk(p, excludes, out);
49
+ const rel = relative(root, p).split("\\").join("/");
50
+ if (isExcluded(entry, rel)) continue;
51
+ if (statSync(p).isDirectory()) walk(root, p, isExcluded, out);
49
52
  else out.push(p);
50
53
  }
51
54
  return out;
52
55
  }
53
56
 
54
- function extractMarkdownOrText(raw, isMarkdown) {
55
- const headings = [];
56
- if (isMarkdown) {
57
- const lines = raw.split("\n");
58
- for (const line of lines) {
59
- const m = /^(#{1,6})\s+(.*)$/.exec(line);
60
- if (m) headings.push({ level: m[1].length, text: m[2].trim(), slug: slug(m[2]) });
61
- }
57
+ /* ---------- extractors ---------- */
58
+ function extractMarkdownHeadings(raw) {
59
+ const headings = []; let inFence = false;
60
+ for (const line of raw.split("\n")) {
61
+ if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; continue; }
62
+ if (inFence) continue;
63
+ const m = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
64
+ if (m) headings.push({ level: m[1].length, text: m[2].trim(), slug: slug(m[2]) });
62
65
  }
63
- const title = headings[0]?.text ?? null;
64
- return { title, headings };
66
+ return headings;
65
67
  }
66
68
 
67
69
  function extractHtml(raw) {
@@ -77,28 +79,84 @@ function extractHtml(raw) {
77
79
  return { title: titleMatch ? titleMatch[1].trim() : headings[0]?.text ?? null, headings, tableCount };
78
80
  }
79
81
 
80
- function extractCsv(raw) {
81
- const lines = raw.split(/\r?\n/).filter(Boolean);
82
- const header = lines[0]?.split(",") ?? [];
83
- return { title: null, rowCount: Math.max(lines.length - 1, 0), columns: header };
82
+ /** RFC-4180-style CSV parsing: quoted fields, escaped quotes (""), commas/newlines inside quotes. */
83
+ export function parseCsv(raw) {
84
+ const rows = []; let row = []; let field = ""; let q = false;
85
+ for (let i = 0; i < raw.length; i++) {
86
+ const c = raw[i];
87
+ if (q) {
88
+ if (c === '"') { if (raw[i + 1] === '"') { field += '"'; i++; } else q = false; }
89
+ else field += c;
90
+ } else if (c === '"') q = true;
91
+ else if (c === ",") { row.push(field); field = ""; }
92
+ else if (c === "\n" || c === "\r") { if (c === "\r" && raw[i + 1] === "\n") i++; row.push(field); field = ""; if (row.some((f) => f !== "")) rows.push(row); row = []; }
93
+ else field += c;
94
+ }
95
+ if (field !== "" || row.length) { row.push(field); if (row.some((f) => f !== "")) rows.push(row); }
96
+ if (q) throw new Error("Unterminated quoted field in CSV");
97
+ return rows;
98
+ }
99
+
100
+ /**
101
+ * Minimal YAML syntax checker (no dependency). Detects common corruption: tab indentation,
102
+ * unbalanced flow collections ([ ] { }), unterminated quotes, and lines that are neither
103
+ * mappings, sequence items, comments, document markers, nor block-scalar continuation.
104
+ * It does NOT parse values, so a passing file is reported as extracted-partial, never full.
105
+ */
106
+ export function checkYaml(raw) {
107
+ const errors = []; const topKeys = [];
108
+ let depth = 0; let blockIndent = -1; let flowStartLine = 0;
109
+ raw.split("\n").forEach((line, i) => {
110
+ const n = i + 1;
111
+ if (/^\t+/.test(line)) errors.push(`line ${n}: tab indentation`);
112
+ const indent = line.match(/^ */)[0].length;
113
+ if (blockIndent >= 0) { if (line.trim() === "" || indent > blockIndent) return; blockIndent = -1; }
114
+ const body = line.replace(/\s+#.*$/, "");
115
+ if (body.trim() === "" || /^\s*#/.test(line) || /^(---|\.\.\.)\s*$/.test(line)) return;
116
+ const depthAtStart = depth;
117
+ let sq = false, dq = false;
118
+ for (let j = 0; j < body.length; j++) {
119
+ const c = body[j];
120
+ if (c === "'" && !dq) sq = !sq; else if (c === '"' && !sq && body[j - 1] !== "\\") dq = !dq;
121
+ else if (!sq && !dq) { if (c === "[" || c === "{") { if (depth === 0) flowStartLine = n; depth++; } else if (c === "]" || c === "}") depth--; }
122
+ }
123
+ if (sq || dq) errors.push(`line ${n}: unterminated quoted string`);
124
+ if (depth < 0) { errors.push(`line ${n}: unmatched closing bracket`); depth = 0; }
125
+ if (depthAtStart === 0) { // only lines that start outside a flow collection must be mapping/sequence entries
126
+ const t = body.trim();
127
+ const ok = /^-(\s|$)/.test(t) || /^[^:\[{]+:(\s|$)/.test(t) || /^["'][^"']*["']\s*:(\s|$)/.test(t) || /^\? /.test(t) || /^[\[{]/.test(t);
128
+ if (!ok) errors.push(`line ${n}: not a mapping, sequence item, or comment`);
129
+ }
130
+ const km = /^([A-Za-z0-9_.-]+)\s*:/.exec(line); if (km && indent === 0) topKeys.push(km[1]);
131
+ if (/:\s*[|>][-+]?\s*$/.test(body)) blockIndent = indent;
132
+ });
133
+ if (depth > 0) errors.push(`line ${flowStartLine}: unclosed flow collection ([ or {) opened here`);
134
+ return { errors, topKeys };
84
135
  }
85
136
 
86
137
  function classify(filePath) {
87
138
  const ext = extname(filePath).toLowerCase();
139
+ const base = basename(filePath).toLowerCase();
88
140
  if (ext === ".md" || ext === ".markdown") return "markdown";
89
141
  if (ext === ".txt") return "text";
90
142
  if (ext === ".json") return "json";
91
143
  if (ext === ".yml" || ext === ".yaml") return "yaml";
92
144
  if (ext === ".csv") return "csv";
93
145
  if (ext === ".html" || ext === ".htm") return "html";
94
- if (TEXT_LIKE_SOURCE_EXT.has(ext)) return "source-code";
146
+ if (TEXT_LIKE_SOURCE_EXT.has(ext) || base.startsWith(".env")) return "source-code";
95
147
  if (UNSUPPORTED_BINARY_EXT.has(ext)) return "unsupported-binary";
96
148
  return "unknown";
97
149
  }
98
150
 
151
+ /** Records one evidence entry per redacted secret: file + line + rule, never the value. */
152
+ function secretEvidence(relPath, findings) {
153
+ return findings.map((f) => ({ source_path: relPath, source_location: `${relPath}#L${f.line}`, heading_path: "[REDACTED-SECRET-LIKE-VALUE]", evidence_type: "redacted-secret", rule: f.rule }));
154
+ }
155
+
99
156
  export function runInventory(corpusPath, { excludes = [] } = {}) {
100
- const allExcludes = [...DEFAULT_EXCLUDES, ...excludes];
101
- const files = walk(corpusPath, allExcludes);
157
+ if (!existsSync(corpusPath) || !statSync(corpusPath).isDirectory()) throw new Error(`Corpus path not found or not a directory: ${corpusPath}`);
158
+ const isExcluded = makeExcluder([...DEFAULT_EXCLUDES, ...excludes]);
159
+ const files = walk(corpusPath, corpusPath, isExcluded).sort();
102
160
  const inventory = [];
103
161
  const evidenceIndex = [];
104
162
 
@@ -106,24 +164,27 @@ export function runInventory(corpusPath, { excludes = [] } = {}) {
106
164
  const relPath = relative(corpusPath, absPath).split("\\").join("/");
107
165
  const kind = classify(absPath);
108
166
  let buf;
109
- try {
110
- buf = readFileSync(absPath);
111
- } catch (err) {
167
+ try { buf = readFileSync(absPath); } catch (err) {
112
168
  inventory.push({ path: relPath, type: kind, title: null, status: "extraction-failed", hash: null, warnings: [String(err.message)] });
113
169
  continue;
114
170
  }
115
- const hash = sha256(buf);
116
- const record = { path: relPath, type: kind, title: null, status: "not-yet-processed", hash, warnings: [] };
117
-
171
+ const record = { path: relPath, type: kind, title: null, status: "not-yet-processed", hash: sha256(buf), warnings: [] };
172
+ const textKinds = new Set(["markdown", "text", "json", "yaml", "csv", "html", "source-code"]);
173
+ let raw = "";
174
+ if (textKinds.has(kind)) {
175
+ const r = redactText(buf.toString("utf8"));
176
+ raw = r.text;
177
+ if (r.findings.length) {
178
+ evidenceIndex.push(...secretEvidence(relPath, r.findings));
179
+ record.warnings.push(`${r.findings.length} secret-like value(s) redacted (lines ${[...new Set(r.findings.map((f) => f.line))].join(", ")}); review and rotate if real.`);
180
+ }
181
+ }
118
182
  try {
119
183
  if (kind === "markdown" || kind === "text") {
120
- const raw = redactSecrets(buf.toString("utf8"));
121
- const { title, headings } = extractMarkdownOrText(raw, kind === "markdown");
122
- record.title = title ?? basename(relPath);
184
+ const headings = kind === "markdown" ? extractMarkdownHeadings(raw) : [];
185
+ record.title = headings[0]?.text ?? basename(relPath);
123
186
  record.status = "extracted-full";
124
- for (const h of headings) {
125
- evidenceIndex.push({ source_path: relPath, source_location: `${relPath}#${h.slug}`, heading_path: h.text, evidence_type: "text", level: h.level });
126
- }
187
+ for (const h of headings) evidenceIndex.push({ source_path: relPath, source_location: `${relPath}#${h.slug}`, heading_path: h.text, evidence_type: "text", level: h.level });
127
188
  } else if (kind === "json") {
128
189
  JSON.parse(buf.toString("utf8"));
129
190
  record.title = basename(relPath);
@@ -131,34 +192,38 @@ export function runInventory(corpusPath, { excludes = [] } = {}) {
131
192
  evidenceIndex.push({ source_path: relPath, source_location: `${relPath}#root`, heading_path: "(structured data)", evidence_type: "structured-data" });
132
193
  } else if (kind === "yaml") {
133
194
  record.title = basename(relPath);
134
- record.status = "extracted-full";
135
- evidenceIndex.push({ source_path: relPath, source_location: `${relPath}#root`, heading_path: "(structured data)", evidence_type: "structured-data" });
195
+ const { errors, topKeys } = checkYaml(raw);
196
+ if (errors.length) {
197
+ record.status = "extraction-failed";
198
+ record.warnings.push(`YAML syntax problems: ${errors.slice(0, 5).join("; ")}`);
199
+ } else {
200
+ record.status = "extracted-partial";
201
+ record.warnings.push("YAML syntax checked by a minimal built-in checker; values are not parsed (top-level keys only).");
202
+ evidenceIndex.push({ source_path: relPath, source_location: `${relPath}#root`, heading_path: `(structured data; top-level keys: ${topKeys.join("|") || "none"})`, evidence_type: "structured-data" });
203
+ }
136
204
  } else if (kind === "csv") {
137
- const raw = buf.toString("utf8");
138
- const { rowCount, columns } = extractCsv(raw);
205
+ const rows = parseCsv(raw);
206
+ const columns = rows[0] ?? [];
207
+ const ragged = rows.slice(1).filter((r) => r.length !== columns.length).length;
139
208
  record.title = basename(relPath);
140
- record.status = "extracted-full";
141
- evidenceIndex.push({ source_path: relPath, source_location: `${relPath}#table`, heading_path: `table (${rowCount} rows, columns: ${columns.join("|")})`, evidence_type: "table" });
209
+ record.status = ragged ? "extracted-partial" : "extracted-full";
210
+ if (ragged) record.warnings.push(`${ragged} row(s) have a different column count than the header.`);
211
+ evidenceIndex.push({ source_path: relPath, source_location: `${relPath}#table`, heading_path: `table (${Math.max(rows.length - 1, 0)} rows, columns: ${columns.join("|")})`, evidence_type: "table", columns });
142
212
  } else if (kind === "html") {
143
- const raw = redactSecrets(buf.toString("utf8"));
144
213
  const { title, headings, tableCount } = extractHtml(raw);
145
214
  record.title = title ?? basename(relPath);
146
- record.status = headings.length || tableCount ? "extracted-partial" : "extracted-full";
147
- if (headings.length === 0 && tableCount === 0) {
148
- // no structure found at all; still a successful text pass
149
- } else {
150
- record.warnings.push("HTML extraction captures headings and tables only; layout, scripts, and embedded visuals are not evaluated.");
151
- }
152
- for (const h of headings) {
153
- evidenceIndex.push({ source_path: relPath, source_location: `${relPath}#${h.slug}`, heading_path: h.text, evidence_type: "text", level: h.level });
154
- }
215
+ record.status = "extracted-partial";
216
+ record.warnings.push("HTML extraction captures headings and tables only; paragraphs, layout, scripts, and embedded visuals are not evaluated.");
217
+ if (tableCount) record.warnings.push(`${tableCount} table(s) present; table contents not indexed.`);
218
+ for (const h of headings) evidenceIndex.push({ source_path: relPath, source_location: `${relPath}#${h.slug}`, heading_path: h.text, evidence_type: "text", level: h.level });
155
219
  } else if (kind === "source-code") {
156
220
  record.title = basename(relPath);
157
221
  record.status = "extracted-full";
158
222
  record.warnings.push("Treated as raw text for reverse-engineering purposes; no language-aware parsing performed.");
223
+ evidenceIndex.push({ source_path: relPath, source_location: `${relPath}#L1-L${raw.split("\n").length}`, heading_path: "(source file)", evidence_type: "code" });
159
224
  } else if (kind === "unsupported-binary") {
160
225
  record.status = "unsupported-format";
161
- record.warnings.push(`Format ${extname(relPath)} requires an external parser or manual export (see references/format-matrix.md). Catalogued by filename only.`);
226
+ record.warnings.push(`Format ${extname(relPath)} requires an external parser or manual export (see references/format-matrix.md). Catalogued by filename only; flag for manual/visual review.`);
162
227
  } else {
163
228
  record.status = "unsupported-format";
164
229
  record.warnings.push("Unknown/unclassified file type; catalogued by filename only.");
@@ -167,47 +232,56 @@ export function runInventory(corpusPath, { excludes = [] } = {}) {
167
232
  record.status = "extraction-failed";
168
233
  record.warnings.push(String(err.message));
169
234
  }
170
-
171
235
  inventory.push(record);
172
236
  }
173
-
174
237
  return { inventory, evidenceIndex };
175
238
  }
176
239
 
177
- function main() {
178
- const [corpusPath, ...rest] = process.argv.slice(2);
179
- if (!corpusPath) {
180
- console.error("Usage: node inventory.mjs <corpusPath> [--out <dir>]");
181
- process.exit(1);
182
- }
183
- let outDir = "./.local-knowledge";
184
- for (let i = 0; i < rest.length; i++) {
185
- if (rest[i] === "--out") outDir = rest[++i];
186
- }
187
- const { inventory, evidenceIndex } = runInventory(corpusPath);
240
+ /** Writes the three standard outputs. Shared by this script's CLI and scripts/cli.mjs. */
241
+ export function writeInventoryOutputs(outDir, corpusPath, { inventory, evidenceIndex }) {
188
242
  mkdirSync(outDir, { recursive: true });
189
243
  writeFileSync(join(outDir, "inventory.json"), JSON.stringify(inventory, null, 2));
190
244
  writeFileSync(join(outDir, "evidence-index.json"), JSON.stringify(evidenceIndex, null, 2));
191
-
192
- const byStatus = {};
193
- for (const r of inventory) byStatus[r.status] = (byStatus[r.status] || 0) + 1;
194
- const summaryLines = [
195
- "# Inventory summary",
196
- "",
197
- `Corpus: \`${corpusPath}\``,
198
- `Total files: ${inventory.length}`,
199
- "",
200
- "## By status",
201
- ...Object.entries(byStatus).map(([k, v]) => `- ${k}: ${v}`),
202
- "",
203
- "## Files needing manual/visual review",
204
- ...inventory.filter((r) => r.status === "unsupported-format" || r.status === "extraction-failed").map((r) => `- ${r.path} (${r.status})`),
245
+ const byStatus = {}; const byType = {};
246
+ for (const r of inventory) { byStatus[r.status] = (byStatus[r.status] || 0) + 1; byType[r.type] = (byType[r.type] || 0) + 1; }
247
+ const abs = resolve(corpusPath); const relToCwd = relative(process.cwd(), abs);
248
+ const corpusLabel = !relToCwd.startsWith("..") && !isAbsolute(relToCwd) ? relToCwd || "." : basename(abs); // never write absolute paths
249
+ const review = inventory.filter((r) => ["unsupported-format", "extraction-failed", "extracted-partial"].includes(r.status));
250
+ const secrets = evidenceIndex.filter((e) => e.evidence_type === "redacted-secret");
251
+ const lines = [
252
+ "# Inventory summary", "", `Corpus: \`${corpusLabel}\``, `Total files: ${inventory.length}`, "",
253
+ "## By status", ...Object.entries(byStatus).map(([k, v]) => `- ${k}: ${v}`), "",
254
+ "## By type", ...Object.entries(byType).map(([k, v]) => `- ${k}: ${v}`), "",
255
+ "## Files needing manual/visual review", ...(review.length ? review.map((r) => `- ${r.path} (${r.status})${r.warnings[0] ? ` — ${r.warnings[0]}` : ""}`) : ["- none"]), "",
256
+ "## Redacted secret-like values", ...(secrets.length ? secrets.map((s) => `- ${s.source_location} (${s.rule})`) : ["- none"]), "",
205
257
  ];
206
- writeFileSync(join(outDir, "INVENTORY_SUMMARY.md"), summaryLines.join("\n") + "\n");
207
- console.log(`Wrote inventory for ${inventory.length} files to ${outDir}`);
208
- console.log(JSON.stringify(byStatus, null, 2));
258
+ writeFileSync(join(outDir, "INVENTORY_SUMMARY.md"), lines.join("\n"));
259
+ return { byStatus, files: ["inventory.json", "evidence-index.json", "INVENTORY_SUMMARY.md"] };
260
+ }
261
+
262
+ export function parseArgs(argv) {
263
+ const opts = { corpusPath: null, outDir: "./.local-knowledge", excludes: [] };
264
+ for (let i = 0; i < argv.length; i++) {
265
+ const a = argv[i];
266
+ if (a === "--out") opts.outDir = argv[++i];
267
+ else if (a === "--exclude") opts.excludes.push(...String(argv[++i] || "").split(",").map((s) => s.trim()).filter(Boolean));
268
+ else if (!opts.corpusPath) opts.corpusPath = a;
269
+ }
270
+ return opts;
209
271
  }
210
272
 
211
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
212
- main();
273
+ function main() {
274
+ const opts = parseArgs(process.argv.slice(2));
275
+ if (!opts.corpusPath) { console.error("Usage: node inventory.mjs <corpusPath> [--out <dir>] [--exclude <name-or-glob>,...]"); process.exit(1); }
276
+ try {
277
+ const result = runInventory(opts.corpusPath, { excludes: opts.excludes });
278
+ const { byStatus } = writeInventoryOutputs(opts.outDir, opts.corpusPath, result);
279
+ console.log(`Wrote inventory for ${result.inventory.length} files to ${opts.outDir}`);
280
+ console.log(JSON.stringify(byStatus, null, 2));
281
+ } catch (err) {
282
+ console.error(`inventory: ${err.message}`);
283
+ process.exit(1);
284
+ }
213
285
  }
286
+
287
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();