@orangepro/orangepro-mcp 0.1.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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +328 -0
  3. package/dist/local/agentWorkflow.js +81 -0
  4. package/dist/local/aiGraph/links.js +635 -0
  5. package/dist/local/analyze/analyzer.js +2129 -0
  6. package/dist/local/analyze/behaviorContracts.js +169 -0
  7. package/dist/local/analyze/boilerplate.js +42 -0
  8. package/dist/local/analyze/callGraph.js +458 -0
  9. package/dist/local/analyze/classify.js +219 -0
  10. package/dist/local/analyze/clustering.js +357 -0
  11. package/dist/local/analyze/confirm.js +2422 -0
  12. package/dist/local/analyze/coverage.js +518 -0
  13. package/dist/local/analyze/coverageArtifacts.js +607 -0
  14. package/dist/local/analyze/frameworks.js +115 -0
  15. package/dist/local/analyze/linkage/conventions.js +160 -0
  16. package/dist/local/analyze/parseCache.js +164 -0
  17. package/dist/local/analyze/selfAssert.js +53 -0
  18. package/dist/local/analyze/symbols.js +430 -0
  19. package/dist/local/analyze/testLayer.js +135 -0
  20. package/dist/local/analyze/treeSitter/engine.js +1253 -0
  21. package/dist/local/analyze/treeSitter/languages.js +101 -0
  22. package/dist/local/autoProve.js +620 -0
  23. package/dist/local/cli.js +1468 -0
  24. package/dist/local/cliArgs.js +112 -0
  25. package/dist/local/corpusScope.js +162 -0
  26. package/dist/local/enrich/csv.js +348 -0
  27. package/dist/local/enrich/index.js +43 -0
  28. package/dist/local/enrich/markdown.js +193 -0
  29. package/dist/local/explain/explain.js +91 -0
  30. package/dist/local/exportCli.js +26 -0
  31. package/dist/local/flows/flowWalker.js +215 -0
  32. package/dist/local/flows/llmFlowDiscovery.js +567 -0
  33. package/dist/local/freshness/changed.js +280 -0
  34. package/dist/local/freshness/manifest.js +35 -0
  35. package/dist/local/freshness/status.js +30 -0
  36. package/dist/local/gaps/gaps.js +114 -0
  37. package/dist/local/generate/buckets.js +73 -0
  38. package/dist/local/generate/compareJudge.js +124 -0
  39. package/dist/local/generate/compareReport.js +538 -0
  40. package/dist/local/generate/compareScore.js +105 -0
  41. package/dist/local/generate/deriveImports.js +91 -0
  42. package/dist/local/generate/generator.js +2586 -0
  43. package/dist/local/generate/prompt.js +144 -0
  44. package/dist/local/generate/promptV5.js +438 -0
  45. package/dist/local/generate/providers.js +400 -0
  46. package/dist/local/generate/runHints.js +304 -0
  47. package/dist/local/graph/citations.js +73 -0
  48. package/dist/local/graph/confirmable.js +72 -0
  49. package/dist/local/graph/factories.js +210 -0
  50. package/dist/local/graph/ontology.js +18 -0
  51. package/dist/local/interactive.js +53 -0
  52. package/dist/local/jobs/jobStore.js +80 -0
  53. package/dist/local/jobs/notify.js +29 -0
  54. package/dist/local/jobs/runner.js +75 -0
  55. package/dist/local/ledger.js +117 -0
  56. package/dist/local/localConfig.js +112 -0
  57. package/dist/local/mcp.js +548 -0
  58. package/dist/local/operations.js +1749 -0
  59. package/dist/local/pack/coverageReport.js +192 -0
  60. package/dist/local/pack/exporter.js +195 -0
  61. package/dist/local/pack/schema.js +128 -0
  62. package/dist/local/pack/summary.js +127 -0
  63. package/dist/local/pack/validate.js +25 -0
  64. package/dist/local/proofRunnability.js +366 -0
  65. package/dist/local/recipe/dbSqljs.js +255 -0
  66. package/dist/local/reprove/paths.js +13 -0
  67. package/dist/local/reprove/scoped.js +136 -0
  68. package/dist/local/resolve/barrelWalker.js +178 -0
  69. package/dist/local/resolve/exportIndex.js +270 -0
  70. package/dist/local/resolve/importGraph.js +347 -0
  71. package/dist/local/resolve/resolver.js +122 -0
  72. package/dist/local/resolve/resolverCache.js +117 -0
  73. package/dist/local/rtm.js +413 -0
  74. package/dist/local/score/coverage.js +99 -0
  75. package/dist/local/score/doctor.js +67 -0
  76. package/dist/local/score/risk.js +362 -0
  77. package/dist/local/score/score.js +182 -0
  78. package/dist/local/types.js +1 -0
  79. package/dist/local/util/hash.js +16 -0
  80. package/dist/local/util/ids.js +16 -0
  81. package/dist/local/util/progress.js +8 -0
  82. package/dist/local/util/redact.js +39 -0
  83. package/dist/local/util/time.js +1 -0
  84. package/dist/local/util/walk.js +174 -0
  85. package/dist/local/viz/behaviorReportData.js +367 -0
  86. package/dist/local/viz/behaviorReportHtml.js +664 -0
  87. package/dist/local/viz/d3.bundle.js +3 -0
  88. package/dist/local/viz/html.js +1152 -0
  89. package/dist/local/viz/payload.js +525 -0
  90. package/dist/local/workspace.js +99 -0
  91. package/docs/agent-workflow.md +167 -0
  92. package/docs/agents/claude-code.md +43 -0
  93. package/docs/agents/codex.md +52 -0
  94. package/docs/agents/cursor.md +39 -0
  95. package/docs/agents/opencode.md +43 -0
  96. package/docs/agents/vscode.md +34 -0
  97. package/docs/local-proof-kit.md +269 -0
  98. package/package.json +92 -0
  99. package/scripts/spikes/dynamic-proof-jest-reporter.cjs +66 -0
  100. package/scripts/spikes/dynamic-proof-mocha-reporter.cjs +105 -0
  101. package/scripts/spikes/dynamic-proof-spike.mjs +2335 -0
  102. package/scripts/spikes/dynamic-proof-vitest-reporter.mjs +81 -0
  103. package/scripts/spikes/failure-summary.mjs +29 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * CLI argument parsing for the OrangePro Local Proof Kit.
3
+ *
4
+ * Extracted from cli.ts so it can be unit-tested without importing the CLI
5
+ * entrypoint (cli.ts runs main() at module load).
6
+ */
7
+ /**
8
+ * Flags that consume the FOLLOWING token as their value (e.g. `--limit 10`).
9
+ * Every other `--flag` is treated as a value-less boolean, so a trailing
10
+ * positional after a boolean flag (e.g. `opro start --no-ai server/public`)
11
+ * is preserved as a positional rather than swallowed as the flag's value.
12
+ */
13
+ export const VALUE_FLAGS = new Set([
14
+ "agent-pass",
15
+ "auto-limit",
16
+ "base",
17
+ "client",
18
+ "coverage-timeout-ms",
19
+ "entity",
20
+ "evidence-ids",
21
+ "format",
22
+ "framework",
23
+ "include-markdown",
24
+ "job-id",
25
+ "limit",
26
+ "jest-config",
27
+ "max-behaviors",
28
+ "max-prompt-tokens",
29
+ "method",
30
+ "min-priority",
31
+ "model",
32
+ "out",
33
+ "paths",
34
+ "pr",
35
+ "prompt-version",
36
+ "provider",
37
+ "replacement",
38
+ "replacement-mode",
39
+ "runner",
40
+ "run-id",
41
+ "seed-field",
42
+ "setup",
43
+ "setup-timeout-ms",
44
+ "source",
45
+ "status",
46
+ "symbols-per-behavior",
47
+ "target",
48
+ "target-file",
49
+ "target-symbol",
50
+ "test",
51
+ "test-env",
52
+ "vitest-config",
53
+ "timeout-ms"
54
+ ]);
55
+ export function parseArgs(argv) {
56
+ const positionals = [];
57
+ const flags = {};
58
+ for (let i = 0; i < argv.length; i++) {
59
+ const token = argv[i];
60
+ if (token.startsWith("--")) {
61
+ const key = token.slice(2);
62
+ const next = argv[i + 1];
63
+ if (VALUE_FLAGS.has(key) && next !== undefined && !next.startsWith("--")) {
64
+ flags[key] = next;
65
+ i++;
66
+ }
67
+ else {
68
+ flags[key] = true;
69
+ }
70
+ }
71
+ else {
72
+ positionals.push(token);
73
+ }
74
+ }
75
+ return { positionals, flags };
76
+ }
77
+ /**
78
+ * Split a `--setup "cmd arg arg"` value into an argv, respecting single/double
79
+ * quotes so a space-bearing arg (e.g. `--schema "a b.prisma"` or `--schema="a b"`)
80
+ * stays one token.
81
+ *
82
+ * ponytail: not a full shell parser — no backslash escapes, env expansion, or
83
+ * unbalanced-quote recovery. For exotic args use the MCP structured setup_commands.
84
+ */
85
+ export function tokenizeSetup(value) {
86
+ const tokens = [];
87
+ const re = /(?:[^\s"']+|"[^"]*"|'[^']*')+/g;
88
+ for (const raw of value.match(re) ?? []) {
89
+ tokens.push(raw.replace(/"([^"]*)"|'([^']*)'/g, (_, dq, sq) => dq ?? sq));
90
+ }
91
+ return tokens;
92
+ }
93
+ /**
94
+ * Collect repeatable `--setup "cmd arg arg"` flags into setup commands. Scans the
95
+ * raw arg list (parseArgs collapses repeated flags to the last value), tokenizing
96
+ * each value quote-aware into { command, args }.
97
+ */
98
+ export function collectSetupCommands(rest) {
99
+ const commands = [];
100
+ for (let i = 0; i < rest.length; i++) {
101
+ if (rest[i] !== "--setup")
102
+ continue;
103
+ const value = rest[i + 1];
104
+ if (typeof value !== "string" || value.startsWith("--"))
105
+ continue;
106
+ const [command, ...args] = tokenizeSetup(value);
107
+ if (command)
108
+ commands.push({ command, args });
109
+ i++;
110
+ }
111
+ return commands;
112
+ }
@@ -0,0 +1,162 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ const DEFAULT_LARGE_FILE_THRESHOLD = 2_000;
4
+ const DEFAULT_LARGE_SCOPE_THRESHOLD = 1_000;
5
+ const MAX_VISITED_FILES = 100_000;
6
+ const SKIP_DIRS = new Set([
7
+ ".cache",
8
+ ".git",
9
+ ".next",
10
+ ".orangepro",
11
+ ".serena",
12
+ ".turbo",
13
+ ".venv",
14
+ "__pycache__",
15
+ "build",
16
+ "coverage",
17
+ "dist",
18
+ "node_modules",
19
+ "target",
20
+ "vendor",
21
+ "venv"
22
+ ]);
23
+ const INCLUDED_EXTENSIONS = new Set([
24
+ ".c",
25
+ ".cc",
26
+ ".cpp",
27
+ ".cs",
28
+ ".css",
29
+ ".go",
30
+ ".h",
31
+ ".hpp",
32
+ ".java",
33
+ ".js",
34
+ ".jsx",
35
+ ".kt",
36
+ ".md",
37
+ ".mdx",
38
+ ".php",
39
+ ".py",
40
+ ".rb",
41
+ ".rs",
42
+ ".swift",
43
+ ".ts",
44
+ ".tsx",
45
+ ".txt",
46
+ ".vue"
47
+ ]);
48
+ const INCLUDED_FILENAMES = new Set([
49
+ "dockerfile",
50
+ "gemfile",
51
+ "go.mod",
52
+ "package.json",
53
+ "pom.xml",
54
+ "requirements.txt"
55
+ ]);
56
+ function extensionOf(name) {
57
+ const idx = name.lastIndexOf(".");
58
+ return idx >= 0 ? name.slice(idx).toLowerCase() : "";
59
+ }
60
+ function isIncludedFile(name) {
61
+ const lower = name.toLowerCase();
62
+ return INCLUDED_FILENAMES.has(lower) || INCLUDED_EXTENSIONS.has(extensionOf(lower));
63
+ }
64
+ function increment(map, key) {
65
+ map.set(key, (map.get(key) ?? 0) + 1);
66
+ }
67
+ function noteFor(files, largeScopeThreshold) {
68
+ if (files >= largeScopeThreshold)
69
+ return "still large — consider one level deeper";
70
+ if (files >= Math.ceil(largeScopeThreshold / 2))
71
+ return "medium scope — usable, but narrower is cheaper";
72
+ return "small enough to graph directly";
73
+ }
74
+ function topEntries(topCounts, childCounts, total, largeScopeThreshold) {
75
+ return [...topCounts.entries()]
76
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
77
+ .map(([path, files]) => ({
78
+ path,
79
+ files,
80
+ share_pct: total > 0 ? Math.round((files / total) * 1000) / 10 : 0,
81
+ note: noteFor(files, largeScopeThreshold),
82
+ children: [...(childCounts.get(path)?.entries() ?? [])]
83
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
84
+ .slice(0, 4)
85
+ .map(([child, childFiles]) => ({ path: child, files: childFiles }))
86
+ }));
87
+ }
88
+ export function summarizeCorpusScope(root, opts = {}) {
89
+ const largeFileThreshold = opts.largeFileThreshold ?? DEFAULT_LARGE_FILE_THRESHOLD;
90
+ const largeScopeThreshold = opts.largeScopeThreshold ?? DEFAULT_LARGE_SCOPE_THRESHOLD;
91
+ const maxVisitedFiles = opts.maxVisitedFiles ?? MAX_VISITED_FILES;
92
+ const topCounts = new Map();
93
+ const childCounts = new Map();
94
+ let files = 0;
95
+ let visited = 0;
96
+ let truncated = false;
97
+ const visit = (relDir) => {
98
+ if (truncated)
99
+ return;
100
+ let entries;
101
+ try {
102
+ entries = readdirSync(join(root, relDir), { withFileTypes: true });
103
+ }
104
+ catch {
105
+ return;
106
+ }
107
+ for (const entry of entries) {
108
+ if (truncated)
109
+ return;
110
+ if (entry.name.startsWith("."))
111
+ continue;
112
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
113
+ if (entry.isDirectory()) {
114
+ if (SKIP_DIRS.has(entry.name))
115
+ continue;
116
+ visit(rel);
117
+ continue;
118
+ }
119
+ if (!entry.isFile() || !isIncludedFile(entry.name))
120
+ continue;
121
+ visited++;
122
+ if (visited > maxVisitedFiles) {
123
+ truncated = true;
124
+ return;
125
+ }
126
+ files++;
127
+ const parts = rel.split("/");
128
+ if (parts.length === 1)
129
+ continue;
130
+ const top = parts[0] || ".";
131
+ increment(topCounts, top);
132
+ if (parts.length > 2) {
133
+ const child = `${top}/${parts[1]}`;
134
+ const children = childCounts.get(top) ?? new Map();
135
+ increment(children, child);
136
+ childCounts.set(top, children);
137
+ }
138
+ }
139
+ };
140
+ visit("");
141
+ const top = topEntries(topCounts, childCounts, files, largeScopeThreshold);
142
+ const isLarge = files >= largeFileThreshold || top.some((entry) => entry.files >= largeScopeThreshold);
143
+ return {
144
+ root,
145
+ files,
146
+ truncated,
147
+ is_large: isLarge,
148
+ thresholds: {
149
+ large_files: largeFileThreshold,
150
+ large_scope_files: largeScopeThreshold
151
+ },
152
+ top_level: top.slice(0, 8),
153
+ suggested_scopes: top.slice(0, 5),
154
+ guidance: isLarge
155
+ ? [
156
+ "OrangePro can still build the deterministic graph locally.",
157
+ "For faster AI grounding and cleaner agent work, start from a focused subdirectory or PR diff.",
158
+ "If a suggested scope is still large, use one of its child directories."
159
+ ]
160
+ : ["This scope is small enough for a direct OrangePro run."]
161
+ };
162
+ }
@@ -0,0 +1,348 @@
1
+ import { makeCandidateEdge, makeEdge, makeNode } from "../graph/factories.js";
2
+ import { hashString } from "../util/hash.js";
3
+ import { slugify } from "../util/ids.js";
4
+ import { redactSecrets } from "../util/redact.js";
5
+ const DETECTOR = "csv_template";
6
+ /** Aliases mapping normalized header tokens onto canonical column keys. */
7
+ const HEADER_ALIASES = {
8
+ behavior_name: "behavior_name",
9
+ behavior: "behavior_name",
10
+ behaviour_name: "behavior_name",
11
+ name: "behavior_name",
12
+ description: "description",
13
+ desc: "description",
14
+ acceptance_criteria: "acceptance_criteria",
15
+ acceptance: "acceptance_criteria",
16
+ ac: "acceptance_criteria",
17
+ actor_or_role: "actor_or_role",
18
+ actor: "actor_or_role",
19
+ role: "actor_or_role",
20
+ actors: "actor_or_role",
21
+ priority_or_risk: "priority_or_risk",
22
+ priority: "priority_or_risk",
23
+ risk: "priority_or_risk",
24
+ source_ref: "source_ref",
25
+ source: "source_ref",
26
+ source_reference: "source_ref",
27
+ workflow_steps: "workflow_steps",
28
+ workflow: "workflow_steps",
29
+ steps: "workflow_steps",
30
+ screen_api_service_or_job: "screen_api_service_or_job",
31
+ screen_api_service_job: "screen_api_service_or_job",
32
+ interface: "screen_api_service_or_job",
33
+ known_edge_cases: "known_edge_cases",
34
+ edge_cases: "known_edge_cases",
35
+ known_bugs_or_incidents: "known_bugs_or_incidents",
36
+ known_bugs: "known_bugs_or_incidents",
37
+ bugs: "known_bugs_or_incidents",
38
+ incidents: "known_bugs_or_incidents",
39
+ manual_qa_steps: "manual_qa_steps",
40
+ manual_qa: "manual_qa_steps",
41
+ existing_test_names_or_links: "existing_test_names_or_links",
42
+ existing_tests: "existing_test_names_or_links",
43
+ existing_test_names: "existing_test_names_or_links",
44
+ related_jira_confluence_testrail_github_ids: "related_jira_confluence_testrail_github_ids",
45
+ related_ids: "related_jira_confluence_testrail_github_ids",
46
+ related: "related_jira_confluence_testrail_github_ids"
47
+ };
48
+ /** Header tokens (any one present) that mark a line as the template header row. */
49
+ const HEADER_SIGNAL_TOKENS = new Set(["behavior_name", "behavior", "behaviour_name", "acceptance_criteria"]);
50
+ /** Normalize a header cell to a comparable token (case/space/underscore-insensitive). */
51
+ function normalizeHeader(cell) {
52
+ return cell
53
+ .trim()
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9]+/g, "_")
56
+ .replace(/^_+|_+$/g, "");
57
+ }
58
+ /**
59
+ * True when the given line plausibly is the OrangePro template header row.
60
+ * Used by the .txt routing fallback in the dispatcher.
61
+ */
62
+ export function looksLikeTemplateHeader(line) {
63
+ const cells = parseCsv(line)[0] ?? [];
64
+ return cells.some((c) => HEADER_SIGNAL_TOKENS.has(normalizeHeader(c)));
65
+ }
66
+ /**
67
+ * Minimal RFC-4180-ish CSV parser. Handles quoted fields, commas/newlines inside
68
+ * quotes, escaped double-quotes (""), and CRLF. Returns rows of string cells.
69
+ */
70
+ function parseCsv(content) {
71
+ const rows = [];
72
+ let row = [];
73
+ let field = "";
74
+ let inQuotes = false;
75
+ let i = 0;
76
+ const n = content.length;
77
+ const pushField = () => {
78
+ row.push(field);
79
+ field = "";
80
+ };
81
+ const pushRow = () => {
82
+ pushField();
83
+ rows.push(row);
84
+ row = [];
85
+ };
86
+ while (i < n) {
87
+ const ch = content[i];
88
+ if (inQuotes) {
89
+ if (ch === '"') {
90
+ if (content[i + 1] === '"') {
91
+ field += '"';
92
+ i += 2;
93
+ continue;
94
+ }
95
+ inQuotes = false;
96
+ i++;
97
+ continue;
98
+ }
99
+ field += ch;
100
+ i++;
101
+ continue;
102
+ }
103
+ if (ch === '"') {
104
+ inQuotes = true;
105
+ i++;
106
+ continue;
107
+ }
108
+ if (ch === ",") {
109
+ pushField();
110
+ i++;
111
+ continue;
112
+ }
113
+ if (ch === "\r") {
114
+ if (content[i + 1] === "\n")
115
+ i++;
116
+ pushRow();
117
+ i++;
118
+ continue;
119
+ }
120
+ if (ch === "\n") {
121
+ pushRow();
122
+ i++;
123
+ continue;
124
+ }
125
+ field += ch;
126
+ i++;
127
+ }
128
+ // Flush trailing field/row unless the content ended with a clean newline and no
129
+ // dangling data.
130
+ if (field.length > 0 || row.length > 0)
131
+ pushRow();
132
+ return rows;
133
+ }
134
+ /** Drop rows that are entirely empty (no non-whitespace cell). */
135
+ function isBlankRow(cells) {
136
+ return cells.every((c) => c.trim() === "");
137
+ }
138
+ /** Split a multi-item cell on ';' or newlines; trims and drops empties. */
139
+ function splitItems(value) {
140
+ return value
141
+ .split(/[;\n\r]+/)
142
+ .map((s) => s.trim())
143
+ .filter((s) => s.length > 0);
144
+ }
145
+ /** Zero-padded requirement index (1 -> "001"). */
146
+ function padIndex(n) {
147
+ return String(n).padStart(3, "0");
148
+ }
149
+ /**
150
+ * Enrich from the OrangePro minimum customer template (CSV).
151
+ *
152
+ * Reviewed template rows are treated as HARD evidence: each data row yields a
153
+ * Requirement node plus hard acceptance-criterion edges, candidate interface
154
+ * edges, and (where present) regression links from incidents. Privacy: only
155
+ * metadata is captured and any free text is run through secret redaction.
156
+ */
157
+ export function enrichFromCsv(relPath, content) {
158
+ const nodes = [];
159
+ const edges = [];
160
+ const candidate_edges = [];
161
+ const warnings = [];
162
+ const sourceScopeId = "manual_template:" + slugify(relPath);
163
+ const source = {
164
+ source_scope_id: sourceScopeId,
165
+ source_system: "manual_template",
166
+ source_type: "customer_supplied",
167
+ display_name: relPath,
168
+ content_hash: hashString(content),
169
+ metadata: { filename: relPath }
170
+ };
171
+ const allRows = parseCsv(content).filter((r) => !isBlankRow(r));
172
+ if (allRows.length === 0) {
173
+ warnings.push(`CSV template '${relPath}' has no rows.`);
174
+ return { nodes, edges, candidate_edges, sources: [source], warnings };
175
+ }
176
+ const headerCells = allRows[0];
177
+ // Only treat a CSV as an OrangePro template if it carries a strong signal
178
+ // column. A bare data CSV (e.g. `id,name,qty`) must NOT become requirements.
179
+ const hasTemplateSignal = headerCells.some((c) => HEADER_SIGNAL_TOKENS.has(normalizeHeader(c)));
180
+ if (!hasTemplateSignal) {
181
+ return {
182
+ nodes: [],
183
+ edges: [],
184
+ candidate_edges: [],
185
+ sources: [],
186
+ warnings: [`CSV '${relPath}' is not an OrangePro template (no behavior_name/acceptance_criteria column); skipped.`]
187
+ };
188
+ }
189
+ const columnIndex = new Map();
190
+ for (let c = 0; c < headerCells.length; c++) {
191
+ const key = HEADER_ALIASES[normalizeHeader(headerCells[c])];
192
+ if (key && !columnIndex.has(key))
193
+ columnIndex.set(key, c);
194
+ }
195
+ if (!columnIndex.has("behavior_name")) {
196
+ warnings.push(`CSV template '${relPath}' is missing a recognizable 'behavior_name' column.`);
197
+ }
198
+ const cell = (row, key) => {
199
+ const idx = columnIndex.get(key);
200
+ if (idx === undefined)
201
+ return "";
202
+ const raw = row[idx];
203
+ return raw === undefined ? "" : redactSecrets(raw).trim();
204
+ };
205
+ const dataRows = allRows.slice(1);
206
+ let reqIndex = 0;
207
+ for (let r = 0; r < dataRows.length; r++) {
208
+ const row = dataRows[r];
209
+ const behaviorName = cell(row, "behavior_name");
210
+ if (behaviorName === "")
211
+ continue;
212
+ reqIndex++;
213
+ const rowNumber = r + 2; // 1-based, accounting for the header row.
214
+ const reqExternalId = "REQ-" + padIndex(reqIndex) + "-" + slugify(behaviorName);
215
+ const description = cell(row, "description");
216
+ const actorsRaw = cell(row, "actor_or_role");
217
+ const actors = splitItems(actorsRaw);
218
+ const priority = cell(row, "priority_or_risk");
219
+ const sourceRef = cell(row, "source_ref");
220
+ const workflowSteps = splitItems(cell(row, "workflow_steps"));
221
+ const screenApi = cell(row, "screen_api_service_or_job");
222
+ const knownEdgeCases = splitItems(cell(row, "known_edge_cases"));
223
+ const knownBugs = splitItems(cell(row, "known_bugs_or_incidents"));
224
+ const manualQaSteps = cell(row, "manual_qa_steps");
225
+ const existingTestNames = splitItems(cell(row, "existing_test_names_or_links"));
226
+ const relatedIds = cell(row, "related_jira_confluence_testrail_github_ids");
227
+ const acceptanceCriteria = splitItems(cell(row, "acceptance_criteria"));
228
+ const rowProvenance = {
229
+ source_scope_id: sourceScopeId,
230
+ source_ref: sourceRef || `${relPath}#row=${rowNumber}`,
231
+ detector: DETECTOR
232
+ };
233
+ const properties = {};
234
+ if (description)
235
+ properties.description = description;
236
+ if (acceptanceCriteria.length > 0)
237
+ properties.acceptance_criteria = acceptanceCriteria;
238
+ if (actors.length > 0)
239
+ properties.actors = actors;
240
+ if (priority)
241
+ properties.priority = priority;
242
+ if (workflowSteps.length > 0)
243
+ properties.workflow_steps = workflowSteps;
244
+ if (screenApi)
245
+ properties.screen_api_service_or_job = screenApi;
246
+ if (knownEdgeCases.length > 0)
247
+ properties.known_edge_cases = knownEdgeCases;
248
+ if (knownBugs.length > 0)
249
+ properties.known_bugs = knownBugs;
250
+ if (manualQaSteps)
251
+ properties.manual_qa_steps = manualQaSteps;
252
+ if (existingTestNames.length > 0)
253
+ properties.existing_test_names = existingTestNames;
254
+ if (relatedIds)
255
+ properties.related_ids = relatedIds;
256
+ nodes.push(makeNode({
257
+ kind: "Requirement",
258
+ external_id: reqExternalId,
259
+ title: behaviorName,
260
+ properties,
261
+ evidence_strength: "hard",
262
+ review_status: "local_reviewed",
263
+ confidence: 0.95,
264
+ provenance: rowProvenance,
265
+ content_hash: hashString(behaviorName + "|" + description),
266
+ behavior_source: "requirement_template",
267
+ denominator_eligible: true,
268
+ denominator_reason: "Reviewed template row — explicit behavior."
269
+ }));
270
+ // Acceptance criteria → hard AcceptanceCriterion nodes + HAS_ACCEPTANCE_CRITERION edges.
271
+ for (let a = 0; a < acceptanceCriteria.length; a++) {
272
+ const acText = acceptanceCriteria[a];
273
+ const acExternalId = "AC-" + slugify(behaviorName) + "-" + padIndex(a + 1);
274
+ nodes.push(makeNode({
275
+ kind: "AcceptanceCriterion",
276
+ external_id: acExternalId,
277
+ title: acText,
278
+ properties: { text: acText, behavior: reqExternalId },
279
+ evidence_strength: "hard",
280
+ review_status: "local_reviewed",
281
+ confidence: 0.95,
282
+ provenance: rowProvenance,
283
+ content_hash: hashString(acText)
284
+ }));
285
+ edges.push(makeEdge({
286
+ from_external_id: reqExternalId,
287
+ to_external_id: acExternalId,
288
+ relationship_type: "HAS_ACCEPTANCE_CRITERION",
289
+ evidence_strength: "hard",
290
+ review_status: "local_reviewed",
291
+ provenance: rowProvenance
292
+ }));
293
+ }
294
+ // Interface field → Service/Endpoint node + candidate MAY_REQUIRE_INTERFACE edge.
295
+ if (screenApi) {
296
+ const ifaceSlug = slugify(screenApi);
297
+ const ifaceExternalId = "iface:" + ifaceSlug;
298
+ const isEndpoint = /\/|https?:|\bapi\b|endpoint|route|\bget\b|\bpost\b|\bput\b|\bdelete\b/i.test(screenApi);
299
+ nodes.push(makeNode({
300
+ kind: isEndpoint ? "Endpoint" : "Service",
301
+ external_id: ifaceExternalId,
302
+ title: screenApi,
303
+ properties: { name: screenApi, inferred_from: "template_interface_field" },
304
+ evidence_strength: "candidate",
305
+ review_status: "inferred",
306
+ confidence: 0.6,
307
+ provenance: rowProvenance
308
+ }));
309
+ candidate_edges.push(makeCandidateEdge({
310
+ from_external_id: reqExternalId,
311
+ to_external_id: ifaceExternalId,
312
+ relationship_type: "MAY_REQUIRE_INTERFACE",
313
+ evidence_strength: "candidate",
314
+ reason: "from template interface field",
315
+ confidence: 0.6,
316
+ provenance: rowProvenance
317
+ }));
318
+ }
319
+ // Known bugs/incidents → Incident node + hard REGRESSION_OF edge (Incident → behavior).
320
+ for (let b = 0; b < knownBugs.length; b++) {
321
+ const bugText = knownBugs[b];
322
+ const incidentExternalId = "incident:" + slugify(behaviorName) + "-" + padIndex(b + 1);
323
+ nodes.push(makeNode({
324
+ kind: "Incident",
325
+ external_id: incidentExternalId,
326
+ title: bugText,
327
+ properties: { summary: bugText, behavior: reqExternalId },
328
+ evidence_strength: "hard",
329
+ review_status: "local_reviewed",
330
+ confidence: 0.95,
331
+ provenance: rowProvenance,
332
+ content_hash: hashString(bugText)
333
+ }));
334
+ edges.push(makeEdge({
335
+ from_external_id: incidentExternalId,
336
+ to_external_id: reqExternalId,
337
+ relationship_type: "REGRESSION_OF",
338
+ evidence_strength: "hard",
339
+ review_status: "local_reviewed",
340
+ provenance: rowProvenance
341
+ }));
342
+ }
343
+ }
344
+ if (reqIndex === 0) {
345
+ warnings.push(`CSV template '${relPath}' produced no behaviors (no populated 'behavior_name' rows).`);
346
+ }
347
+ return { nodes, edges, candidate_edges, sources: [source], warnings };
348
+ }
@@ -0,0 +1,43 @@
1
+ import { enrichFromCsv, looksLikeTemplateHeader } from "./csv.js";
2
+ import { enrichFromMarkdown } from "./markdown.js";
3
+ export { enrichFromCsv } from "./csv.js";
4
+ export { enrichFromMarkdown } from "./markdown.js";
5
+ /** Lowercased file extension including the leading dot (e.g. ".csv"); "" if none. */
6
+ function extensionOf(relPath) {
7
+ const base = relPath.slice(relPath.lastIndexOf("/") + 1);
8
+ const dot = base.lastIndexOf(".");
9
+ return dot <= 0 ? "" : base.slice(dot).toLowerCase();
10
+ }
11
+ /** First non-empty line of the content, used for .txt header sniffing. */
12
+ function firstLine(content) {
13
+ for (const line of content.split(/\r\n|\r|\n/)) {
14
+ if (line.trim() !== "")
15
+ return line;
16
+ }
17
+ return "";
18
+ }
19
+ /**
20
+ * Dispatch enrichment by file type.
21
+ *
22
+ * `.csv` → CSV template; `.md`/`.mdx`/`.markdown` → Markdown docs. A `.txt`
23
+ * file whose first line looks like the template header is routed to CSV.
24
+ * Returns null for unsupported content.
25
+ */
26
+ export function enrichFromContent(relPath, content) {
27
+ const ext = extensionOf(relPath);
28
+ let fragment = null;
29
+ if (ext === ".csv")
30
+ fragment = enrichFromCsv(relPath, content);
31
+ else if (ext === ".md" || ext === ".mdx" || ext === ".markdown")
32
+ fragment = enrichFromMarkdown(relPath, content);
33
+ else if (ext === ".txt" && looksLikeTemplateHeader(firstLine(content)))
34
+ fragment = enrichFromCsv(relPath, content);
35
+ if (!fragment)
36
+ return null;
37
+ // A source that yields zero anchors/evidence is noise — keep the diagnostic
38
+ // warning but do not register the SourceScope (e.g. a non-template data CSV).
39
+ if (fragment.nodes.length === 0) {
40
+ return { nodes: [], edges: [], candidate_edges: [], sources: [], warnings: fragment.warnings };
41
+ }
42
+ return fragment;
43
+ }