decision-guardian 1.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.
- package/LICENSE +21 -0
- package/README.md +792 -0
- package/dist/adapters/github/actions-logger.js +88 -0
- package/dist/adapters/github/comment.js +601 -0
- package/dist/adapters/github/github-provider.js +260 -0
- package/dist/adapters/github/health.js +56 -0
- package/dist/adapters/local/console-logger.js +46 -0
- package/dist/adapters/local/local-git-provider.js +247 -0
- package/dist/cli/commands/check.js +134 -0
- package/dist/cli/commands/init.js +58 -0
- package/dist/cli/commands/template.js +70 -0
- package/dist/cli/formatter.js +68 -0
- package/dist/cli/index.js +12458 -0
- package/dist/cli/licenses.txt +143 -0
- package/dist/cli/paths.js +40 -0
- package/dist/core/content-matchers.js +333 -0
- package/dist/core/health.js +52 -0
- package/dist/core/interfaces/index.js +2 -0
- package/dist/core/interfaces/logger.js +2 -0
- package/dist/core/interfaces/scm-provider.js +5 -0
- package/dist/core/logger.js +20 -0
- package/dist/core/matcher.js +184 -0
- package/dist/core/metrics.js +87 -0
- package/dist/core/parser.js +338 -0
- package/dist/core/rule-evaluator.js +186 -0
- package/dist/core/rule-parser.js +211 -0
- package/dist/core/rule-types.js +22 -0
- package/dist/core/trie.js +83 -0
- package/dist/core/types.js +2 -0
- package/dist/index.js +61142 -0
- package/dist/licenses.txt +758 -0
- package/dist/main.js +290 -0
- package/dist/telemetry/payload.js +25 -0
- package/dist/telemetry/privacy.js +37 -0
- package/dist/telemetry/sender.js +40 -0
- package/dist/version.js +7 -0
- package/package.json +60 -0
- package/templates/advanced-rules.md +94 -0
- package/templates/api.md +70 -0
- package/templates/basic.md +38 -0
- package/templates/database.md +81 -0
- package/templates/security.md +89 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FileMatcher = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* FileMatcher — matches changed files against decision patterns.
|
|
6
|
+
*/
|
|
7
|
+
const minimatch_1 = require("minimatch");
|
|
8
|
+
const trie_1 = require("./trie");
|
|
9
|
+
const rule_evaluator_1 = require("./rule-evaluator");
|
|
10
|
+
const metrics_1 = require("./metrics");
|
|
11
|
+
class FileMatcher {
|
|
12
|
+
normalizedDecisions;
|
|
13
|
+
trie;
|
|
14
|
+
ruleEvaluator;
|
|
15
|
+
logger;
|
|
16
|
+
constructor(decisions, logger) {
|
|
17
|
+
this.logger = logger;
|
|
18
|
+
this.ruleEvaluator = new rule_evaluator_1.RuleEvaluator(logger);
|
|
19
|
+
this.normalizedDecisions = decisions.map((d) => ({
|
|
20
|
+
...d,
|
|
21
|
+
files: d.files.map((f) => f.replace(/\\/g, '/').normalize('NFC')),
|
|
22
|
+
}));
|
|
23
|
+
const activeDecisions = this.normalizedDecisions.filter((d) => d.status === 'active');
|
|
24
|
+
this.trie = new trie_1.PatternTrie(activeDecisions);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Find matches using advanced rules
|
|
28
|
+
*/
|
|
29
|
+
async findMatchesWithDiffs(fileDiffs) {
|
|
30
|
+
const activeDecisions = this.normalizedDecisions.filter((d) => d.status === 'active');
|
|
31
|
+
this.logger.debug(`FileMatcher: ${activeDecisions.length} active decisions loaded.`);
|
|
32
|
+
metrics_1.metrics.addDecisionsEvaluated(activeDecisions.length);
|
|
33
|
+
const matches = [];
|
|
34
|
+
const ruleDecisions = activeDecisions.filter((d) => d.rules);
|
|
35
|
+
const patternDecisions = activeDecisions.filter((d) => !d.rules);
|
|
36
|
+
if (patternDecisions.length > 0) {
|
|
37
|
+
const patternDecisionSet = new Set(patternDecisions);
|
|
38
|
+
const decisionMatches = new Map();
|
|
39
|
+
for (const fileDiff of fileDiffs) {
|
|
40
|
+
const normalizedFile = fileDiff.filename.replace(/\\/g, '/').normalize('NFC');
|
|
41
|
+
const candidates = this.trie.findCandidates(normalizedFile);
|
|
42
|
+
for (const decision of candidates) {
|
|
43
|
+
if (!patternDecisionSet.has(decision))
|
|
44
|
+
continue;
|
|
45
|
+
const matchedPattern = this.matchesDecision(normalizedFile, decision);
|
|
46
|
+
if (matchedPattern) {
|
|
47
|
+
if (!decisionMatches.has(decision)) {
|
|
48
|
+
decisionMatches.set(decision, { files: [], patterns: new Set() });
|
|
49
|
+
}
|
|
50
|
+
const matchData = decisionMatches.get(decision);
|
|
51
|
+
matchData.files.push(normalizedFile);
|
|
52
|
+
matchData.patterns.add(matchedPattern);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const [decision, data] of decisionMatches) {
|
|
57
|
+
for (const file of data.files) {
|
|
58
|
+
const matchedPattern = this.matchesDecision(file, decision);
|
|
59
|
+
if (matchedPattern) {
|
|
60
|
+
matches.push({
|
|
61
|
+
file,
|
|
62
|
+
decision,
|
|
63
|
+
matchedPattern,
|
|
64
|
+
matchDetails: {
|
|
65
|
+
matched: true,
|
|
66
|
+
matchedFiles: [file],
|
|
67
|
+
matchedPatterns: [matchedPattern],
|
|
68
|
+
ruleDepth: 0,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (ruleDecisions.length > 0) {
|
|
76
|
+
const CONCURRENCY = 50;
|
|
77
|
+
const totalBatches = Math.ceil(ruleDecisions.length / CONCURRENCY);
|
|
78
|
+
for (let i = 0; i < ruleDecisions.length; i += CONCURRENCY) {
|
|
79
|
+
const batchNum = Math.floor(i / CONCURRENCY) + 1;
|
|
80
|
+
this.logger.debug(`Processing rule batch ${batchNum}/${totalBatches}...`);
|
|
81
|
+
const batch = ruleDecisions.slice(i, i + CONCURRENCY);
|
|
82
|
+
const batchResults = await Promise.allSettled(batch.map(async (decision) => {
|
|
83
|
+
const result = await this.ruleEvaluator.evaluate(decision.rules, fileDiffs);
|
|
84
|
+
if (result.matched) {
|
|
85
|
+
return {
|
|
86
|
+
file: result.matchedFiles.join(', '),
|
|
87
|
+
decision,
|
|
88
|
+
matchedPattern: result.matchedPatterns.slice(0, 3).join(', '),
|
|
89
|
+
matchDetails: result,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}));
|
|
94
|
+
const successfulResults = batchResults
|
|
95
|
+
.filter((r) => r.status === 'fulfilled' && r.value !== null)
|
|
96
|
+
.map((r) => r.value);
|
|
97
|
+
const failures = batchResults.filter((r) => r.status === 'rejected');
|
|
98
|
+
if (failures.length > 0) {
|
|
99
|
+
this.logger.warning(`${failures.length} decision evaluations failed in this batch. Check debug logs for details.`);
|
|
100
|
+
failures.forEach((f) => this.logger.debug(`Decision evaluation failed: ${f.reason}`));
|
|
101
|
+
}
|
|
102
|
+
matches.push(...successfulResults);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return matches.sort((a, b) => {
|
|
106
|
+
return activeDecisions.indexOf(a.decision) - activeDecisions.indexOf(b.decision);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Find all decisions that protect the given changed files
|
|
111
|
+
*/
|
|
112
|
+
async findMatches(changedFiles) {
|
|
113
|
+
const activeDecisions = this.normalizedDecisions.filter((d) => d.status === 'active');
|
|
114
|
+
metrics_1.metrics.addDecisionsEvaluated(activeDecisions.length);
|
|
115
|
+
const CHUNK_SIZE = 500;
|
|
116
|
+
if (changedFiles.length > CHUNK_SIZE) {
|
|
117
|
+
const chunks = [];
|
|
118
|
+
for (let i = 0; i < changedFiles.length; i += CHUNK_SIZE) {
|
|
119
|
+
chunks.push(changedFiles.slice(i, i + CHUNK_SIZE));
|
|
120
|
+
}
|
|
121
|
+
const results = chunks.map((chunk) => this.processChunk(chunk));
|
|
122
|
+
return results.flat();
|
|
123
|
+
}
|
|
124
|
+
return this.processChunk(changedFiles);
|
|
125
|
+
}
|
|
126
|
+
processChunk(files) {
|
|
127
|
+
const matches = [];
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
const normalizedFile = file.replace(/\\/g, '/');
|
|
130
|
+
const candidates = this.trie.findCandidates(normalizedFile);
|
|
131
|
+
for (const decision of candidates) {
|
|
132
|
+
const matchedPattern = this.matchesDecision(normalizedFile, decision);
|
|
133
|
+
if (matchedPattern) {
|
|
134
|
+
matches.push({ file: normalizedFile, decision, matchedPattern });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return matches;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Check if a file matches any pattern in a decision
|
|
142
|
+
*/
|
|
143
|
+
matchesDecision(file, decision) {
|
|
144
|
+
let matchedPattern = null;
|
|
145
|
+
let isMatch = false;
|
|
146
|
+
for (const pattern of decision.files) {
|
|
147
|
+
if (pattern.startsWith('!')) {
|
|
148
|
+
if (this.matchesPattern(file, pattern.substring(1))) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
if (this.matchesPattern(file, pattern)) {
|
|
154
|
+
isMatch = true;
|
|
155
|
+
matchedPattern = pattern;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return isMatch ? matchedPattern : null;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Check if a file matches a glob pattern
|
|
163
|
+
*/
|
|
164
|
+
matchesPattern(file, pattern) {
|
|
165
|
+
const normalizedFile = file.normalize('NFC');
|
|
166
|
+
return (0, minimatch_1.minimatch)(normalizedFile, pattern, {
|
|
167
|
+
dot: true,
|
|
168
|
+
matchBase: false,
|
|
169
|
+
nocase: false,
|
|
170
|
+
nobrace: false,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Group matches by severity for prioritization
|
|
175
|
+
*/
|
|
176
|
+
groupBySeverity(matches) {
|
|
177
|
+
return {
|
|
178
|
+
critical: matches.filter((m) => m.decision.severity === 'critical'),
|
|
179
|
+
warning: matches.filter((m) => m.decision.severity === 'warning'),
|
|
180
|
+
info: matches.filter((m) => m.decision.severity === 'info'),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
exports.FileMatcher = FileMatcher;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* MetricsCollector — Platform-agnostic performance metrics.
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.metrics = exports.MetricsCollector = void 0;
|
|
7
|
+
class MetricsCollector {
|
|
8
|
+
data = {
|
|
9
|
+
api_calls: 0,
|
|
10
|
+
api_errors: 0,
|
|
11
|
+
rate_limit_hits: 0,
|
|
12
|
+
files_processed: 0,
|
|
13
|
+
decisions_evaluated: 0,
|
|
14
|
+
matches_found: 0,
|
|
15
|
+
critical_matches: 0,
|
|
16
|
+
warning_matches: 0,
|
|
17
|
+
info_matches: 0,
|
|
18
|
+
duration_ms: 0,
|
|
19
|
+
parse_errors: 0,
|
|
20
|
+
parse_warnings: 0,
|
|
21
|
+
};
|
|
22
|
+
incrementApiCall() {
|
|
23
|
+
this.data.api_calls++;
|
|
24
|
+
}
|
|
25
|
+
incrementApiError() {
|
|
26
|
+
this.data.api_errors++;
|
|
27
|
+
}
|
|
28
|
+
incrementRateLimitHit() {
|
|
29
|
+
this.data.rate_limit_hits++;
|
|
30
|
+
}
|
|
31
|
+
addFilesProcessed(count) {
|
|
32
|
+
this.data.files_processed += count;
|
|
33
|
+
}
|
|
34
|
+
addDecisionsEvaluated(count) {
|
|
35
|
+
this.data.decisions_evaluated += count;
|
|
36
|
+
}
|
|
37
|
+
addMatchesFound(count) {
|
|
38
|
+
this.data.matches_found += count;
|
|
39
|
+
}
|
|
40
|
+
addCriticalMatches(count) {
|
|
41
|
+
this.data.critical_matches += count;
|
|
42
|
+
}
|
|
43
|
+
addWarningMatches(count) {
|
|
44
|
+
this.data.warning_matches += count;
|
|
45
|
+
}
|
|
46
|
+
addInfoMatches(count) {
|
|
47
|
+
this.data.info_matches += count;
|
|
48
|
+
}
|
|
49
|
+
setDuration(ms) {
|
|
50
|
+
this.data.duration_ms = ms;
|
|
51
|
+
}
|
|
52
|
+
addParseErrors(count) {
|
|
53
|
+
this.data.parse_errors += count;
|
|
54
|
+
}
|
|
55
|
+
addParseWarnings(count) {
|
|
56
|
+
this.data.parse_warnings += count;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Returns an immutable snapshot of collected metrics.
|
|
60
|
+
* Callers decide how to output: console, Actions output, telemetry, etc.
|
|
61
|
+
*/
|
|
62
|
+
getSnapshot() {
|
|
63
|
+
return { ...this.data };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Reset all metrics to zero (useful for testing)
|
|
67
|
+
*/
|
|
68
|
+
reset() {
|
|
69
|
+
this.data = {
|
|
70
|
+
api_calls: 0,
|
|
71
|
+
api_errors: 0,
|
|
72
|
+
rate_limit_hits: 0,
|
|
73
|
+
files_processed: 0,
|
|
74
|
+
decisions_evaluated: 0,
|
|
75
|
+
matches_found: 0,
|
|
76
|
+
critical_matches: 0,
|
|
77
|
+
warning_matches: 0,
|
|
78
|
+
info_matches: 0,
|
|
79
|
+
duration_ms: 0,
|
|
80
|
+
parse_errors: 0,
|
|
81
|
+
parse_warnings: 0,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
exports.MetricsCollector = MetricsCollector;
|
|
86
|
+
/** Shared singleton instance */
|
|
87
|
+
exports.metrics = new MetricsCollector();
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DecisionParser = void 0;
|
|
37
|
+
const fs = __importStar(require("fs/promises"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const rule_parser_1 = require("./rule-parser");
|
|
40
|
+
class DecisionParser {
|
|
41
|
+
ruleParser = new rule_parser_1.RuleParser();
|
|
42
|
+
STATUS_SYNONYMS = {
|
|
43
|
+
active: 'active',
|
|
44
|
+
enabled: 'active',
|
|
45
|
+
live: 'active',
|
|
46
|
+
deprecated: 'deprecated',
|
|
47
|
+
obsolete: 'deprecated',
|
|
48
|
+
superseded: 'superseded',
|
|
49
|
+
replaced: 'superseded',
|
|
50
|
+
archived: 'archived',
|
|
51
|
+
inactive: 'archived',
|
|
52
|
+
};
|
|
53
|
+
SEVERITY_SYNONYMS = {
|
|
54
|
+
info: 'info',
|
|
55
|
+
informational: 'info',
|
|
56
|
+
low: 'info',
|
|
57
|
+
warning: 'warning',
|
|
58
|
+
warn: 'warning',
|
|
59
|
+
medium: 'warning',
|
|
60
|
+
critical: 'critical',
|
|
61
|
+
error: 'critical',
|
|
62
|
+
high: 'critical',
|
|
63
|
+
blocker: 'critical',
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Parse a decisions.md file
|
|
67
|
+
*/
|
|
68
|
+
async parseFile(filePath) {
|
|
69
|
+
const workspaceRoot = process.env.GITHUB_WORKSPACE || process.cwd();
|
|
70
|
+
const resolvedPath = path.resolve(workspaceRoot, filePath);
|
|
71
|
+
const relativePath = path.relative(workspaceRoot, resolvedPath);
|
|
72
|
+
const isSafe = relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
|
|
73
|
+
if (!isSafe) {
|
|
74
|
+
return {
|
|
75
|
+
decisions: [],
|
|
76
|
+
errors: [
|
|
77
|
+
{
|
|
78
|
+
line: 0,
|
|
79
|
+
message: `Security: Path traversal detected - ${filePath}`,
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
warnings: [],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const stat = await fs.stat(resolvedPath);
|
|
87
|
+
if (stat.isDirectory()) {
|
|
88
|
+
return this.parseDirectory(resolvedPath);
|
|
89
|
+
}
|
|
90
|
+
const content = await fs.readFile(resolvedPath, 'utf-8');
|
|
91
|
+
return await this.parseContent(content, resolvedPath);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
95
|
+
return {
|
|
96
|
+
decisions: [],
|
|
97
|
+
errors: [
|
|
98
|
+
{
|
|
99
|
+
line: 0,
|
|
100
|
+
message: `Failed to read file: ${message}`,
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
warnings: [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Recursively parse a directory for rule files
|
|
109
|
+
*/
|
|
110
|
+
async parseDirectory(dirPath) {
|
|
111
|
+
const combinedResult = {
|
|
112
|
+
decisions: [],
|
|
113
|
+
errors: [],
|
|
114
|
+
warnings: [],
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
120
|
+
if (entry.isDirectory()) {
|
|
121
|
+
// Skip hidden directories like .git
|
|
122
|
+
if (entry.name.startsWith('.'))
|
|
123
|
+
continue;
|
|
124
|
+
const subResult = await this.parseDirectory(fullPath);
|
|
125
|
+
this.mergeResults(combinedResult, subResult);
|
|
126
|
+
}
|
|
127
|
+
else if (entry.isFile() &&
|
|
128
|
+
(entry.name.endsWith('.md') || entry.name.endsWith('.markdown'))) {
|
|
129
|
+
try {
|
|
130
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
131
|
+
const fileResult = await this.parseContent(content, fullPath);
|
|
132
|
+
this.mergeResults(combinedResult, fileResult);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
combinedResult.errors.push({
|
|
136
|
+
line: 0,
|
|
137
|
+
message: `Failed to parse ${entry.name}: ${err instanceof Error ? err.message : String(err)}`,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
combinedResult.errors.push({
|
|
145
|
+
line: 0,
|
|
146
|
+
message: `Failed to list directory ${dirPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return combinedResult;
|
|
150
|
+
}
|
|
151
|
+
mergeResults(target, source) {
|
|
152
|
+
target.decisions.push(...source.decisions);
|
|
153
|
+
target.errors.push(...source.errors);
|
|
154
|
+
target.warnings.push(...source.warnings);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Parse markdown content into decisions
|
|
158
|
+
*/
|
|
159
|
+
async parseContent(content, sourceFile) {
|
|
160
|
+
const decisions = [];
|
|
161
|
+
const errors = [];
|
|
162
|
+
const warnings = [];
|
|
163
|
+
const blocks = this.splitIntoBlocks(content);
|
|
164
|
+
for (const block of blocks) {
|
|
165
|
+
try {
|
|
166
|
+
const decision = await this.parseBlock(block, sourceFile, warnings);
|
|
167
|
+
if (!decision.id || !decision.title) {
|
|
168
|
+
errors.push({
|
|
169
|
+
line: block.lineNumber,
|
|
170
|
+
message: `Decision missing required fields (id or title)`,
|
|
171
|
+
context: block.raw.substring(0, 100),
|
|
172
|
+
});
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
decisions.push(decision);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
179
|
+
errors.push({
|
|
180
|
+
line: block.lineNumber,
|
|
181
|
+
message,
|
|
182
|
+
context: block.raw.substring(0, 100),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { decisions, errors, warnings };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Split content into decision blocks
|
|
190
|
+
*/
|
|
191
|
+
splitIntoBlocks(content) {
|
|
192
|
+
if (!content.trim()) {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
const blocks = [];
|
|
196
|
+
const markerPattern = /<!--\s*DECISION-(?:[A-Z0-9]+-)*[A-Z0-9]+\s*-->/gi;
|
|
197
|
+
let match;
|
|
198
|
+
const markers = [];
|
|
199
|
+
while ((match = markerPattern.exec(content)) !== null) {
|
|
200
|
+
markers.push(match.index);
|
|
201
|
+
}
|
|
202
|
+
// Split at markers
|
|
203
|
+
for (let i = 0; i < markers.length; i++) {
|
|
204
|
+
const start = markers[i];
|
|
205
|
+
const end = markers[i + 1] || content.length;
|
|
206
|
+
const blockContent = content.substring(start, end);
|
|
207
|
+
blocks.push({
|
|
208
|
+
raw: blockContent,
|
|
209
|
+
lineNumber: this.computeLineStart(content, start),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return blocks;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Compute the line number where a block starts
|
|
216
|
+
*/
|
|
217
|
+
computeLineStart(fullContent, startIndex) {
|
|
218
|
+
const before = fullContent.substring(0, startIndex);
|
|
219
|
+
return before.split(/\r?\n/).length;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Parse a single decision block
|
|
223
|
+
*/
|
|
224
|
+
async parseBlock(block, sourceFile, warnings) {
|
|
225
|
+
const content = block.raw;
|
|
226
|
+
const idMatch = content.match(/<!--\s*(DECISION-(?:[A-Z0-9]+-)*[A-Z0-9]+)\s*-->/i);
|
|
227
|
+
const id = idMatch ? idMatch[1].toUpperCase() : '';
|
|
228
|
+
const titleMatch = content.match(/^##\s*Decision:\s*(.+)$/im);
|
|
229
|
+
const title = titleMatch ? titleMatch[1].trim() : '';
|
|
230
|
+
const statusRaw = this.extractField(content, 'Status', 'active');
|
|
231
|
+
const date = this.extractField(content, 'Date', new Date().toISOString().split('T')[0]);
|
|
232
|
+
const severityRaw = this.extractField(content, 'Severity', 'info');
|
|
233
|
+
this.validateDate(date, id, warnings);
|
|
234
|
+
const files = this.extractFilesList(content);
|
|
235
|
+
const ruleResult = await this.ruleParser.extractRules(content, sourceFile);
|
|
236
|
+
if (ruleResult.error) {
|
|
237
|
+
warnings.push(`${id}: ${ruleResult.error}`);
|
|
238
|
+
}
|
|
239
|
+
const contextMatch = content.match(/###\s*Context\s*\n([\s\S]+?)(?=\n---+|\n<!--|$)/);
|
|
240
|
+
const context = contextMatch ? contextMatch[1].trim() : '';
|
|
241
|
+
return {
|
|
242
|
+
id,
|
|
243
|
+
title,
|
|
244
|
+
date,
|
|
245
|
+
status: this.normalizeStatus(statusRaw),
|
|
246
|
+
severity: this.normalizeSeverity(severityRaw),
|
|
247
|
+
schemaVersion: 1,
|
|
248
|
+
files,
|
|
249
|
+
rules: ruleResult.rules ?? undefined,
|
|
250
|
+
context,
|
|
251
|
+
sourceFile,
|
|
252
|
+
lineNumber: block.lineNumber,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Extract a metadata field like "**Status**: Active"
|
|
257
|
+
*/
|
|
258
|
+
extractField(content, fieldName, defaultValue) {
|
|
259
|
+
const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
260
|
+
const regex = new RegExp(`^\\*\\*${escaped}\\*\\*:\\s*(.+)$`, 'im');
|
|
261
|
+
const match = content.match(regex);
|
|
262
|
+
return match ? match[1].trim() : defaultValue;
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Extract list of file patterns
|
|
266
|
+
*/
|
|
267
|
+
extractFilesList(content) {
|
|
268
|
+
const files = [];
|
|
269
|
+
const filesMatch = content.match(/\*\*Files\*\*:\s*\n/);
|
|
270
|
+
if (!filesMatch || filesMatch.index === undefined) {
|
|
271
|
+
return files;
|
|
272
|
+
}
|
|
273
|
+
const startPos = filesMatch.index + filesMatch[0].length;
|
|
274
|
+
const remainingContent = content.substring(startPos);
|
|
275
|
+
const lines = remainingContent.split('\n');
|
|
276
|
+
for (const line of lines) {
|
|
277
|
+
const withBackticks = line.match(/^\s*[-*]\s*`([^`]+)`\s*$/);
|
|
278
|
+
const withoutBackticks = line.match(/^\s*[-*]\s+([^\s`]+)\s*$/);
|
|
279
|
+
if (withBackticks) {
|
|
280
|
+
files.push(withBackticks[1].trim());
|
|
281
|
+
}
|
|
282
|
+
else if (withoutBackticks) {
|
|
283
|
+
files.push(withoutBackticks[1].trim());
|
|
284
|
+
}
|
|
285
|
+
else if (line.trim() !== '') {
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return files;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Validate date format and provide warnings
|
|
293
|
+
*/
|
|
294
|
+
validateDate(dateString, decisionId, warnings) {
|
|
295
|
+
if (!dateString)
|
|
296
|
+
return;
|
|
297
|
+
const isoRegex = /^\d{4}-\d{2}-\d{2}$/;
|
|
298
|
+
if (!isoRegex.test(dateString)) {
|
|
299
|
+
warnings.push(`Decision ${decisionId}: Invalid date format '${dateString}' - use YYYY-MM-DD`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const parsed = new Date(dateString + 'T00:00:00Z');
|
|
303
|
+
if (isNaN(parsed.getTime())) {
|
|
304
|
+
warnings.push(`Decision ${decisionId}: Invalid date format '${dateString}' - use YYYY-MM-DD`);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const [year, month, day] = dateString.split('-').map(Number);
|
|
308
|
+
if (parsed.getUTCFullYear() !== year ||
|
|
309
|
+
parsed.getUTCMonth() + 1 !== month ||
|
|
310
|
+
parsed.getUTCDate() !== day) {
|
|
311
|
+
warnings.push(`Decision ${decisionId}: Invalid date '${dateString}' (day doesn't exist)`);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const now = new Date();
|
|
315
|
+
const tenYearsAgo = new Date(now.getFullYear() - 10, 0, 1);
|
|
316
|
+
if (parsed > now) {
|
|
317
|
+
warnings.push(`Decision ${decisionId}: Date is in the future - is this correct?`);
|
|
318
|
+
}
|
|
319
|
+
else if (parsed < tenYearsAgo) {
|
|
320
|
+
warnings.push(`Decision ${decisionId}: Date is >10 years old - consider archiving`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Normalize status using synonyms
|
|
325
|
+
*/
|
|
326
|
+
normalizeStatus(status) {
|
|
327
|
+
const normalized = status.toLowerCase().trim();
|
|
328
|
+
return this.STATUS_SYNONYMS[normalized] || 'active';
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Normalize severity using synonyms
|
|
332
|
+
*/
|
|
333
|
+
normalizeSeverity(severity) {
|
|
334
|
+
const normalized = severity.toLowerCase().trim();
|
|
335
|
+
return this.SEVERITY_SYNONYMS[normalized] || 'info';
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
exports.DecisionParser = DecisionParser;
|