@thanbir-tamim/enterprise-project-intelligence-agent 0.1.1 → 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.
- package/CHANGELOG.md +14 -0
- package/README.md +302 -49
- package/assets/figures/feasibility-scoring-engine.png +0 -0
- package/assets/figures/hero-architecture.png +0 -0
- package/assets/figures/skills-interaction-map.png +0 -0
- package/docs/SUPPORTED_FORMATS.md +4 -4
- package/examples/synthetic-inventory-system/ai-opportunity-matrix/AI_OPPORTUNITY_MATRIX.md +4 -4
- package/examples/synthetic-inventory-system/generated-docs/INVENTORY_SUMMARY.md +17 -0
- package/examples/synthetic-inventory-system/generated-docs/evidence-index.json +78 -0
- package/examples/synthetic-inventory-system/generated-docs/inventory.json +26 -0
- package/examples/synthetic-inventory-system/presentation/DEMO_DECK.md +69 -55
- package/examples/synthetic-inventory-system/presentation/demo-deck.json +194 -16
- package/package.json +2 -1
- package/scripts/cli.mjs +20 -14
- package/scripts/lib/skills.mjs +1 -1
- package/scripts/release-audit.mjs +73 -70
- package/skills/corporate-presentation-generator/SKILL.md +49 -23
- package/skills/corporate-presentation-generator/scripts/outline-to-deck-json.mjs +77 -0
- package/skills/documentation-quality-auditor/SKILL.md +28 -21
- package/skills/documentation-quality-auditor/references/audit-checks.md +15 -8
- package/skills/documentation-quality-auditor/scripts/audit-docs.mjs +192 -32
- package/skills/engineering-knowledge-transfer/SKILL.md +1 -5
- package/skills/enterprise-ai-integration-planner/SKILL.md +13 -15
- package/skills/enterprise-ai-integration-planner/references/feasibility-scoring.md +26 -5
- package/skills/enterprise-ai-integration-planner/scripts/score-opportunity.mjs +32 -15
- package/skills/presentation-compliance-validator/SKILL.md +15 -15
- package/skills/presentation-compliance-validator/references/rule-schema.md +67 -10
- package/skills/presentation-compliance-validator/scripts/validate-deck.mjs +48 -11
- package/skills/project-documentation-discovery/SKILL.md +25 -8
- package/skills/project-documentation-discovery/references/format-matrix.md +4 -4
- package/skills/project-documentation-discovery/scripts/inventory.mjs +174 -100
- package/skills/project-documentation-discovery/scripts/secrets.mjs +99 -0
- package/skills/project-knowledge-assistant/SKILL.md +1 -7
- package/skills/software-change-impact-analyzer/SKILL.md +15 -11
- package/skills/software-reverse-engineering/SKILL.md +14 -10
- package/skills/software-reverse-engineering/references/system-map-schema.md +27 -0
- package/skills/technical-document-generator/SKILL.md +6 -8
- 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
|
|
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.
|
|
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
|
-
(
|
|
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 (
|
|
104
|
+
if (deckObj[key]) {
|
|
75
105
|
for (const [prop, expected] of Object.entries(rules[key])) {
|
|
76
|
-
const actual =
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
14
|
-
| CSV (`.csv`) |
|
|
15
|
-
| HTML (`.html`, `.htm`) | Regex-based heading/table
|
|
16
|
-
| Source code / config (`.ts`, `.js`, `.py`, `.java`, `.go`, `.rb`, `.sql`, `.env
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
return
|
|
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,
|
|
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
|
|
48
|
-
if (
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
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
|
-
|
|
101
|
-
const
|
|
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
|
|
116
|
-
const
|
|
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
|
|
121
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
|
138
|
-
const
|
|
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
|
-
|
|
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 =
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
`Total files: ${inventory.length}`,
|
|
199
|
-
"",
|
|
200
|
-
"## By
|
|
201
|
-
...
|
|
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"),
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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();
|