@adrkit/core 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.
- package/README.md +22 -0
- package/dist/LICENSE +201 -0
- package/dist/NOTICE +11 -0
- package/dist/affects/catalog.d.ts +14 -0
- package/dist/affects/index.d.ts +30 -0
- package/dist/affects/inert.d.ts +8 -0
- package/dist/affects/matchers/package.d.ts +20 -0
- package/dist/affects/matchers/path.d.ts +5 -0
- package/dist/check/index.d.ts +46 -0
- package/dist/graph/build.d.ts +18 -0
- package/dist/import/classify.d.ts +13 -0
- package/dist/import/fingerprint.d.ts +2 -0
- package/dist/import/index.d.ts +32 -0
- package/dist/import/madr.d.ts +21 -0
- package/dist/import/merge.d.ts +21 -0
- package/dist/import/status.d.ts +11 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1768 -0
- package/dist/load/corpus.d.ts +20 -0
- package/dist/parse/frontmatter.d.ts +10 -0
- package/dist/scaffold/new.d.ts +26 -0
- package/dist/schema/adr.schema.d.ts +416 -0
- package/dist/schema/adr.schema.js +182 -0
- package/dist/schema/emit.cli.d.ts +1 -0
- package/dist/schema/emit.d.ts +6 -0
- package/dist/schema/emit.js +200 -0
- package/dist/schema/index.d.ts +2 -0
- package/dist/schema/index.js +220 -0
- package/dist/validate/contract.d.ts +9 -0
- package/dist/validate/corpus-invariants.d.ts +3 -0
- package/dist/validate/findings.d.ts +19 -0
- package/dist/validate/import-incomplete.d.ts +3 -0
- package/dist/validate/index.d.ts +13 -0
- package/package.json +76 -0
- package/src/affects/catalog.ts +17 -0
- package/src/affects/index.ts +240 -0
- package/src/affects/inert.ts +63 -0
- package/src/affects/matchers/package.ts +102 -0
- package/src/affects/matchers/path.ts +37 -0
- package/src/check/index.ts +121 -0
- package/src/graph/build.ts +94 -0
- package/src/import/classify.ts +53 -0
- package/src/import/fingerprint.ts +10 -0
- package/src/import/index.ts +240 -0
- package/src/import/madr.ts +117 -0
- package/src/import/merge.ts +321 -0
- package/src/import/status.ts +48 -0
- package/src/index.ts +13 -0
- package/src/load/corpus.ts +101 -0
- package/src/parse/frontmatter.ts +73 -0
- package/src/scaffold/new.ts +208 -0
- package/src/schema/adr.schema.ts +338 -0
- package/src/schema/emit.cli.ts +9 -0
- package/src/schema/emit.ts +55 -0
- package/src/schema/index.ts +2 -0
- package/src/validate/contract.ts +120 -0
- package/src/validate/corpus-invariants.ts +77 -0
- package/src/validate/findings.ts +51 -0
- package/src/validate/import-incomplete.ts +23 -0
- package/src/validate/index.ts +68 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { Adr } from '../schema/adr.schema.ts';
|
|
2
|
+
import { sortFindings, type Finding, type FindingSeverity } from '../validate/findings.ts';
|
|
3
|
+
import type { CatalogSnapshot } from './catalog.ts';
|
|
4
|
+
import { isAlwaysInertType, matchEntityPattern } from './inert.ts';
|
|
5
|
+
import { matchPackagePattern, type ChangedDependency } from './matchers/package.ts';
|
|
6
|
+
import { matchPathPattern } from './matchers/path.ts';
|
|
7
|
+
|
|
8
|
+
export type { CatalogPort, CatalogSnapshot, CatalogSnapshotEntity, EntityId } from './catalog.ts';
|
|
9
|
+
export {
|
|
10
|
+
deriveChangedDependenciesFromBunLockDiff,
|
|
11
|
+
matchPackagePattern,
|
|
12
|
+
parsePackagePattern,
|
|
13
|
+
type ChangedDependency,
|
|
14
|
+
type PackageMatcherResult,
|
|
15
|
+
type ParsedPackagePattern,
|
|
16
|
+
} from './matchers/package.ts';
|
|
17
|
+
// Neutral repo-relative path-glob primitive. Exposed so `@adrkit/evaluator` can reuse
|
|
18
|
+
// the exact matcher grammar for `path` target resolution instead of duplicating it
|
|
19
|
+
// (research §R4). Evaluator-specific target-registry concepts stay out of core.
|
|
20
|
+
export { matchPathPattern, type PathMatcherResult } from './matchers/path.ts';
|
|
21
|
+
|
|
22
|
+
export interface ResolutionSnapshots {
|
|
23
|
+
changedDependencies?: readonly ChangedDependency[];
|
|
24
|
+
catalog?: CatalogSnapshot;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ResolveAffectsInput {
|
|
28
|
+
records: readonly Adr[];
|
|
29
|
+
changedFiles: readonly string[];
|
|
30
|
+
snapshots?: ResolutionSnapshots;
|
|
31
|
+
log?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface FiredMatcher {
|
|
35
|
+
type: string;
|
|
36
|
+
pattern: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AffectsMatch {
|
|
40
|
+
recordId: string;
|
|
41
|
+
firedMatchers: FiredMatcher[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ResolveAffectsResult {
|
|
45
|
+
matches: AffectsMatch[];
|
|
46
|
+
findings: Finding[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Matcher = Adr['frontmatter']['affects'][number] & {
|
|
50
|
+
type: string;
|
|
51
|
+
pattern: string;
|
|
52
|
+
repo?: string;
|
|
53
|
+
negate?: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
interface MatcherEvaluation {
|
|
57
|
+
matched: boolean;
|
|
58
|
+
findings: Finding[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function compareFiredMatcher(a: FiredMatcher, b: FiredMatcher): number {
|
|
62
|
+
return a.type.localeCompare(b.type) || a.pattern.localeCompare(b.pattern);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function uniqueSortedFiredMatchers(matchers: readonly FiredMatcher[]): FiredMatcher[] {
|
|
66
|
+
const byKey = new Map<string, FiredMatcher>();
|
|
67
|
+
for (const matcher of matchers) {
|
|
68
|
+
byKey.set(`${matcher.type}\0${matcher.pattern}`, matcher);
|
|
69
|
+
}
|
|
70
|
+
return [...byKey.values()].sort(compareFiredMatcher);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function affectsFinding(
|
|
74
|
+
record: Adr,
|
|
75
|
+
matcher: Matcher,
|
|
76
|
+
rule: string,
|
|
77
|
+
severity: FindingSeverity,
|
|
78
|
+
message: string,
|
|
79
|
+
): Finding {
|
|
80
|
+
return {
|
|
81
|
+
rule,
|
|
82
|
+
severity,
|
|
83
|
+
message,
|
|
84
|
+
id: record.frontmatter.id,
|
|
85
|
+
path: record.path,
|
|
86
|
+
field: `affects.${matcher.type}`,
|
|
87
|
+
pattern: matcher.pattern,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function matcherAppliesToLog(matcher: Matcher, log: string | undefined): boolean {
|
|
92
|
+
return !matcher.repo || matcher.repo === log;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function evaluateMatcher(
|
|
96
|
+
record: Adr,
|
|
97
|
+
matcher: Matcher,
|
|
98
|
+
changedFiles: readonly string[],
|
|
99
|
+
snapshots: ResolutionSnapshots | undefined,
|
|
100
|
+
): MatcherEvaluation {
|
|
101
|
+
if (matcher.type === 'path') {
|
|
102
|
+
const result = matchPathPattern(matcher.pattern, changedFiles);
|
|
103
|
+
if (result.badPattern) {
|
|
104
|
+
return {
|
|
105
|
+
matched: false,
|
|
106
|
+
findings: [
|
|
107
|
+
affectsFinding(
|
|
108
|
+
record,
|
|
109
|
+
matcher,
|
|
110
|
+
'affects-bad-pattern',
|
|
111
|
+
'warn',
|
|
112
|
+
result.badPattern === 'leading-slash'
|
|
113
|
+
? `Path matcher "${matcher.pattern}" must be repo-relative and must not start with "/".`
|
|
114
|
+
: `Path matcher "${matcher.pattern}" is not a valid glob pattern.`,
|
|
115
|
+
),
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return { matched: result.matched, findings: [] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (matcher.type === 'package') {
|
|
123
|
+
const result = matchPackagePattern(matcher.pattern, snapshots?.changedDependencies);
|
|
124
|
+
if (result.unresolvable) {
|
|
125
|
+
return {
|
|
126
|
+
matched: false,
|
|
127
|
+
findings: [
|
|
128
|
+
affectsFinding(
|
|
129
|
+
record,
|
|
130
|
+
matcher,
|
|
131
|
+
'affects-unresolvable',
|
|
132
|
+
'info',
|
|
133
|
+
`Package matcher "${matcher.pattern}" requires a changed-dependency snapshot and is inert.`,
|
|
134
|
+
),
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (result.badPattern) {
|
|
139
|
+
return {
|
|
140
|
+
matched: false,
|
|
141
|
+
findings: [
|
|
142
|
+
affectsFinding(
|
|
143
|
+
record,
|
|
144
|
+
matcher,
|
|
145
|
+
'affects-bad-pattern',
|
|
146
|
+
'warn',
|
|
147
|
+
`Package matcher "${matcher.pattern}" must be "name" or "name@<valid semver range>".`,
|
|
148
|
+
),
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return { matched: result.matched, findings: [] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (matcher.type === 'entity') {
|
|
156
|
+
const result = matchEntityPattern(matcher.pattern, changedFiles, snapshots?.catalog);
|
|
157
|
+
if (result.unresolvable) {
|
|
158
|
+
return {
|
|
159
|
+
matched: false,
|
|
160
|
+
findings: [
|
|
161
|
+
affectsFinding(
|
|
162
|
+
record,
|
|
163
|
+
matcher,
|
|
164
|
+
'affects-unresolvable',
|
|
165
|
+
'info',
|
|
166
|
+
`Entity matcher "${matcher.pattern}" has no catalog snapshot and is inert.`,
|
|
167
|
+
),
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
return { matched: result.matched, findings: [] };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (isAlwaysInertType(matcher.type)) {
|
|
175
|
+
return {
|
|
176
|
+
matched: false,
|
|
177
|
+
findings: [
|
|
178
|
+
affectsFinding(
|
|
179
|
+
record,
|
|
180
|
+
matcher,
|
|
181
|
+
'affects-unresolvable',
|
|
182
|
+
'info',
|
|
183
|
+
`${matcher.type} matcher "${matcher.pattern}" has no backing snapshot in this phase and is inert.`,
|
|
184
|
+
),
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// TODO(phase: schema-evolution): Allow unknown affects `type` values to pass
|
|
190
|
+
// validation as a warning so this forward-compat branch is reachable for real records.
|
|
191
|
+
// Requires an ADR-0002/0009 schema decision.
|
|
192
|
+
return {
|
|
193
|
+
matched: false,
|
|
194
|
+
findings: [
|
|
195
|
+
affectsFinding(
|
|
196
|
+
record,
|
|
197
|
+
matcher,
|
|
198
|
+
'affects-unknown-type',
|
|
199
|
+
'warn',
|
|
200
|
+
`Affects matcher type "${matcher.type}" is not recognized by this adrkit version and was ignored.`,
|
|
201
|
+
),
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function resolveAffects(input: ResolveAffectsInput): ResolveAffectsResult {
|
|
207
|
+
const matches: AffectsMatch[] = [];
|
|
208
|
+
const findings: Finding[] = [];
|
|
209
|
+
|
|
210
|
+
for (const record of input.records) {
|
|
211
|
+
const firedMatchers: FiredMatcher[] = [];
|
|
212
|
+
let suppressed = false;
|
|
213
|
+
|
|
214
|
+
for (const matcher of (record.frontmatter.affects ?? []) as readonly Matcher[]) {
|
|
215
|
+
if (!matcherAppliesToLog(matcher, input.log)) continue;
|
|
216
|
+
|
|
217
|
+
const result = evaluateMatcher(record, matcher, input.changedFiles, input.snapshots);
|
|
218
|
+
findings.push(...result.findings);
|
|
219
|
+
|
|
220
|
+
if (!result.matched) continue;
|
|
221
|
+
if (matcher.negate) {
|
|
222
|
+
suppressed = true;
|
|
223
|
+
} else {
|
|
224
|
+
firedMatchers.push({ type: matcher.type, pattern: matcher.pattern });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (!suppressed && firedMatchers.length > 0) {
|
|
229
|
+
matches.push({
|
|
230
|
+
recordId: record.frontmatter.id,
|
|
231
|
+
firedMatchers: uniqueSortedFiredMatchers(firedMatchers),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
matches: matches.sort((a, b) => a.recordId.localeCompare(b.recordId)),
|
|
238
|
+
findings: sortFindings(findings),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import picomatch from 'picomatch';
|
|
2
|
+
import type { CatalogSnapshot, EntityId } from './catalog.ts';
|
|
3
|
+
|
|
4
|
+
export type InertMatcherType = 'entity' | 'resource' | 'api' | 'data';
|
|
5
|
+
|
|
6
|
+
export interface EntityMatcherResult {
|
|
7
|
+
matched: boolean;
|
|
8
|
+
unresolvable?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function compilePattern(pattern: string): (candidate: string) => boolean {
|
|
12
|
+
return picomatch(pattern, { dot: false, nocase: false, nonegate: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function resolveEntityIds(pattern: string, catalog: CatalogSnapshot): Set<EntityId> {
|
|
16
|
+
const ids = new Set<EntityId>();
|
|
17
|
+
const isMatch = compilePattern(pattern);
|
|
18
|
+
for (const entity of catalog.entities) {
|
|
19
|
+
const refs = [entity.id, ...(entity.refs ?? [])];
|
|
20
|
+
if (refs.some((ref) => isMatch(ref))) {
|
|
21
|
+
ids.add(entity.id);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return ids;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function entitiesForPaths(changedFiles: readonly string[], catalog: CatalogSnapshot): Set<EntityId> {
|
|
28
|
+
const ids = new Set<EntityId>();
|
|
29
|
+
for (const entity of catalog.entities) {
|
|
30
|
+
for (const entityPath of entity.paths ?? []) {
|
|
31
|
+
const isMatch = compilePattern(entityPath);
|
|
32
|
+
if (changedFiles.some((path) => isMatch(path))) {
|
|
33
|
+
ids.add(entity.id);
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return ids;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function matchEntityPattern(
|
|
42
|
+
pattern: string,
|
|
43
|
+
changedFiles: readonly string[],
|
|
44
|
+
catalog: CatalogSnapshot | undefined,
|
|
45
|
+
): EntityMatcherResult {
|
|
46
|
+
if (!catalog) {
|
|
47
|
+
return { matched: false, unresolvable: true };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const entityIds = resolveEntityIds(pattern, catalog);
|
|
51
|
+
if (entityIds.size === 0) {
|
|
52
|
+
return { matched: false };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const changedEntityIds = entitiesForPaths(changedFiles, catalog);
|
|
56
|
+
return {
|
|
57
|
+
matched: [...entityIds].some((id) => changedEntityIds.has(id)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isAlwaysInertType(type: string): type is Exclude<InertMatcherType, 'entity'> {
|
|
62
|
+
return type === 'resource' || type === 'api' || type === 'data';
|
|
63
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import semver from 'semver';
|
|
2
|
+
|
|
3
|
+
export interface ChangedDependency {
|
|
4
|
+
name: string;
|
|
5
|
+
version: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ParsedPackagePattern {
|
|
9
|
+
name: string;
|
|
10
|
+
range?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PackageMatcherResult {
|
|
14
|
+
matched: boolean;
|
|
15
|
+
unresolvable?: boolean;
|
|
16
|
+
badPattern?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parsePackagePattern(pattern: string): ParsedPackagePattern | undefined {
|
|
20
|
+
const splitAt = pattern.lastIndexOf('@');
|
|
21
|
+
const hasRange = splitAt > 0;
|
|
22
|
+
const name = hasRange ? pattern.slice(0, splitAt) : pattern;
|
|
23
|
+
const range = hasRange ? pattern.slice(splitAt + 1).trim() : undefined;
|
|
24
|
+
|
|
25
|
+
if (!name.trim() || (hasRange && !range)) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (range && !semver.validRange(range)) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return range ? { name, range } : { name };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function matchPackagePattern(
|
|
37
|
+
pattern: string,
|
|
38
|
+
changedDependencies: readonly ChangedDependency[] | undefined,
|
|
39
|
+
): PackageMatcherResult {
|
|
40
|
+
const parsed = parsePackagePattern(pattern);
|
|
41
|
+
if (!parsed) {
|
|
42
|
+
return { matched: false, badPattern: true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!changedDependencies) {
|
|
46
|
+
return { matched: false, unresolvable: true };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
matched: changedDependencies.some(
|
|
51
|
+
(dependency) =>
|
|
52
|
+
dependency.name === parsed.name &&
|
|
53
|
+
(!parsed.range || semver.satisfies(dependency.version, parsed.range)),
|
|
54
|
+
),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseBunLockPackageLine(line: string): ChangedDependency | undefined {
|
|
59
|
+
const match = line.match(/^\s*"([^"]+)":\s*\["([^"]+)"/);
|
|
60
|
+
if (!match) return undefined;
|
|
61
|
+
|
|
62
|
+
const [, name, descriptor] = match;
|
|
63
|
+
if (!name || !descriptor?.startsWith(`${name}@`)) return undefined;
|
|
64
|
+
|
|
65
|
+
const version = descriptor.slice(name.length + 1);
|
|
66
|
+
if (!semver.valid(version)) return undefined;
|
|
67
|
+
|
|
68
|
+
return { name, version };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Derives the v1 package snapshot from a unified diff of `bun.lock`.
|
|
73
|
+
* Only Bun's text lockfile package entries are supported.
|
|
74
|
+
*/
|
|
75
|
+
export function deriveChangedDependenciesFromBunLockDiff(diff: string): ChangedDependency[] {
|
|
76
|
+
const added = new Map<string, Set<string>>();
|
|
77
|
+
const removed = new Map<string, Set<string>>();
|
|
78
|
+
|
|
79
|
+
for (const line of diff.split(/\r?\n/)) {
|
|
80
|
+
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
|
81
|
+
const bucket = line.startsWith('+') ? added : line.startsWith('-') ? removed : undefined;
|
|
82
|
+
if (!bucket) continue;
|
|
83
|
+
|
|
84
|
+
const dependency = parseBunLockPackageLine(line.slice(1));
|
|
85
|
+
if (!dependency) continue;
|
|
86
|
+
|
|
87
|
+
let versions = bucket.get(dependency.name);
|
|
88
|
+
if (!versions) {
|
|
89
|
+
versions = new Set<string>();
|
|
90
|
+
bucket.set(dependency.name, versions);
|
|
91
|
+
}
|
|
92
|
+
versions.add(dependency.version);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const changedNames = new Set([...added.keys(), ...removed.keys()]);
|
|
96
|
+
return [...changedNames]
|
|
97
|
+
.flatMap((name) => {
|
|
98
|
+
const versions = new Set([...(added.get(name) ?? []), ...(removed.get(name) ?? [])]);
|
|
99
|
+
return [...versions].map((version) => ({ name, version }));
|
|
100
|
+
})
|
|
101
|
+
.sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));
|
|
102
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import picomatch from 'picomatch';
|
|
2
|
+
|
|
3
|
+
export interface PathMatcherResult {
|
|
4
|
+
matched: boolean;
|
|
5
|
+
badPattern?: 'leading-slash' | 'invalid-glob';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function normalizeCandidate(path: string): string {
|
|
9
|
+
return path.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function hasDotSegment(path: string): boolean {
|
|
13
|
+
return path.split('/').some((segment) => segment.startsWith('.') && segment !== '.' && segment !== '..');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function patternAllowsDotSegment(pattern: string): boolean {
|
|
17
|
+
return pattern.split('/').some((segment) => segment.startsWith('.') && segment !== '.' && segment !== '..');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function matchPathPattern(pattern: string, changedFiles: readonly string[]): PathMatcherResult {
|
|
21
|
+
if (pattern.startsWith('/')) {
|
|
22
|
+
return { matched: false, badPattern: 'leading-slash' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const isMatch = picomatch(pattern, { dot: false, nocase: false, nonegate: true });
|
|
27
|
+
const allowsDot = patternAllowsDotSegment(pattern);
|
|
28
|
+
return {
|
|
29
|
+
matched: changedFiles.some((path) => {
|
|
30
|
+
const candidate = normalizeCandidate(path);
|
|
31
|
+
return (!hasDotSegment(candidate) || allowsDot) && isMatch(candidate);
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
return { matched: false, badPattern: 'invalid-glob' };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Adr } from '../schema/adr.schema.ts';
|
|
2
|
+
import { resolveAffects, type FiredMatcher, type ResolutionSnapshots } from '../affects/index.ts';
|
|
3
|
+
import { sortFindings, type Finding } from '../validate/findings.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The full result of `lintCorpus` — records, findings, and the checked count.
|
|
7
|
+
* `checkChanges` takes the whole result (not just `records`) because `lintCorpus`
|
|
8
|
+
* drops malformed files from `records` while keeping their `error` findings; those
|
|
9
|
+
* errors must still count toward `ok` when the malformed file was changed (RC3/R1).
|
|
10
|
+
*/
|
|
11
|
+
export interface CheckLintResult {
|
|
12
|
+
records: Adr[];
|
|
13
|
+
findings: Finding[];
|
|
14
|
+
checked: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GoverningDecision {
|
|
18
|
+
recordId: string;
|
|
19
|
+
title: string;
|
|
20
|
+
firedMatchers: FiredMatcher[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The stable structure `adr check --json` emits and the `@adrkit/ci` Action consumes.
|
|
25
|
+
* Deterministic and pure: identical `(lint, changedFiles, snapshots)` → identical output.
|
|
26
|
+
*/
|
|
27
|
+
export interface CheckOutcome {
|
|
28
|
+
changedFiles: string[];
|
|
29
|
+
governedBy: GoverningDecision[];
|
|
30
|
+
changedRecords: string[];
|
|
31
|
+
findings: Finding[];
|
|
32
|
+
ok: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CheckChangesInput {
|
|
36
|
+
lint: CheckLintResult;
|
|
37
|
+
changedFiles: readonly string[];
|
|
38
|
+
/** Corpus directory (default `docs/adr`) used to identify changed records. */
|
|
39
|
+
dir?: string;
|
|
40
|
+
snapshots?: ResolutionSnapshots;
|
|
41
|
+
/** Optional current-repo identity for scoped (`repo`-qualified) matchers. */
|
|
42
|
+
log?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const RECORD_BASENAME = /^\d{4,}-.+\.md$/;
|
|
46
|
+
const TEMPLATE_BASENAME = '0000-template.md';
|
|
47
|
+
|
|
48
|
+
function normalizeDir(dir: string | undefined): string {
|
|
49
|
+
const forward = (dir ?? 'docs/adr').replace(/\\/g, '/');
|
|
50
|
+
// Strip trailing slashes without a regex (avoids super-linear scanning on
|
|
51
|
+
// pathological input — the changed-file paths are attacker-controlled).
|
|
52
|
+
let end = forward.length;
|
|
53
|
+
while (end > 0 && forward.charCodeAt(end - 1) === 47 /* '/' */) end -= 1;
|
|
54
|
+
const stripped = forward.slice(0, end);
|
|
55
|
+
// A root corpus ("." or now-empty) is the repo root — no path prefix, so
|
|
56
|
+
// repo-relative changed files (which never start with "./") still match.
|
|
57
|
+
return stripped === '.' ? '' : stripped;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toForwardSlash(path: string): string {
|
|
61
|
+
return path.replace(/\\/g, '/');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether a repo-relative changed file is an ADR record under `dir`, using the same
|
|
66
|
+
* flat-directory + filename grammar the corpus loader enforces. Pure string logic —
|
|
67
|
+
* no filesystem access — so a malformed record dropped from `lint.records` is still
|
|
68
|
+
* recognized as a changed record and its `error` findings count toward `ok`.
|
|
69
|
+
*/
|
|
70
|
+
function isCorpusRecordPath(file: string, dir: string): boolean {
|
|
71
|
+
// An empty dir (root corpus) yields an empty prefix, not "/".
|
|
72
|
+
const prefix = dir ? `${dir}/` : '';
|
|
73
|
+
if (!file.startsWith(prefix)) return false;
|
|
74
|
+
const rest = file.slice(prefix.length);
|
|
75
|
+
if (rest.length === 0 || rest.includes('/')) return false;
|
|
76
|
+
return rest !== TEMPLATE_BASENAME && RECORD_BASENAME.test(rest);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function uniqueSorted(values: readonly string[]): string[] {
|
|
80
|
+
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The single, neutral "resolve governing decisions + validate changed records"
|
|
85
|
+
* implementation, called by both `adr check` (CLI) and the `@adrkit/ci` Action so
|
|
86
|
+
* neither surface depends on the other. Pure: no clock, network, or fs traversal
|
|
87
|
+
* beyond the already-loaded lint result (ADR-0009).
|
|
88
|
+
*/
|
|
89
|
+
export function checkChanges(input: CheckChangesInput): CheckOutcome {
|
|
90
|
+
const dir = normalizeDir(input.dir);
|
|
91
|
+
const changedFiles = uniqueSorted(input.changedFiles.map(toForwardSlash));
|
|
92
|
+
const changedRecords = changedFiles.filter((file) => isCorpusRecordPath(file, dir));
|
|
93
|
+
const changedRecordSet = new Set(changedRecords);
|
|
94
|
+
|
|
95
|
+
const resolution = resolveAffects({
|
|
96
|
+
records: input.lint.records,
|
|
97
|
+
changedFiles,
|
|
98
|
+
snapshots: input.snapshots,
|
|
99
|
+
log: input.log,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const titleById = new Map(
|
|
103
|
+
input.lint.records.map((record) => [record.frontmatter.id, record.frontmatter.title]),
|
|
104
|
+
);
|
|
105
|
+
const governedBy: GoverningDecision[] = resolution.matches.map((match) => ({
|
|
106
|
+
recordId: match.recordId,
|
|
107
|
+
title: titleById.get(match.recordId) ?? '',
|
|
108
|
+
firedMatchers: match.firedMatchers,
|
|
109
|
+
}));
|
|
110
|
+
|
|
111
|
+
// Findings kept only for files lint attributes to a changed record. Errors on
|
|
112
|
+
// unchanged records (A5) and corpus-level findings without a record path do not
|
|
113
|
+
// fail the check.
|
|
114
|
+
const changedRecordFindings = input.lint.findings.filter(
|
|
115
|
+
(finding) => finding.path !== undefined && changedRecordSet.has(finding.path),
|
|
116
|
+
);
|
|
117
|
+
const findings = sortFindings([...resolution.findings, ...changedRecordFindings]);
|
|
118
|
+
const ok = !changedRecordFindings.some((finding) => finding.severity === 'error');
|
|
119
|
+
|
|
120
|
+
return { changedFiles, governedBy, changedRecords, findings, ok };
|
|
121
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Adr } from '../schema/adr.schema.ts';
|
|
2
|
+
|
|
3
|
+
export interface GraphNode {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
status: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface GraphEdge {
|
|
10
|
+
from: string;
|
|
11
|
+
to: string;
|
|
12
|
+
kind: 'supersedes' | 'relatesTo' | 'conflictsWith';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AdrGraph {
|
|
16
|
+
nodes: GraphNode[];
|
|
17
|
+
edges: GraphEdge[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function edgeKey(edge: GraphEdge): string {
|
|
21
|
+
return `${edge.from}\u0000${edge.to}\u0000${edge.kind}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function pushEdge(edges: Map<string, GraphEdge>, ids: Set<string>, edge: GraphEdge): void {
|
|
25
|
+
if (!ids.has(edge.from) || !ids.has(edge.to)) return;
|
|
26
|
+
edges.set(edgeKey(edge), edge);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildAdrGraph(records: readonly Adr[]): AdrGraph {
|
|
30
|
+
const sortedRecords = [...records].sort((a, b) => a.frontmatter.id.localeCompare(b.frontmatter.id));
|
|
31
|
+
const ids = new Set(sortedRecords.map((record) => record.frontmatter.id));
|
|
32
|
+
const edges = new Map<string, GraphEdge>();
|
|
33
|
+
|
|
34
|
+
for (const record of sortedRecords) {
|
|
35
|
+
for (const superseded of record.frontmatter.supersedes) {
|
|
36
|
+
pushEdge(edges, ids, { from: record.frontmatter.id, to: superseded, kind: 'supersedes' });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (record.frontmatter.supersededBy) {
|
|
40
|
+
pushEdge(edges, ids, {
|
|
41
|
+
from: record.frontmatter.supersededBy,
|
|
42
|
+
to: record.frontmatter.id,
|
|
43
|
+
kind: 'supersedes',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const related of record.frontmatter.relatesTo) {
|
|
48
|
+
pushEdge(edges, ids, { from: record.frontmatter.id, to: related, kind: 'relatesTo' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const conflict of record.frontmatter.conflictsWith) {
|
|
52
|
+
pushEdge(edges, ids, { from: record.frontmatter.id, to: conflict, kind: 'conflictsWith' });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
nodes: sortedRecords.map((record) => ({
|
|
58
|
+
id: record.frontmatter.id,
|
|
59
|
+
title: record.frontmatter.title,
|
|
60
|
+
status: record.frontmatter.status,
|
|
61
|
+
})),
|
|
62
|
+
edges: [...edges.values()].sort(
|
|
63
|
+
(a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to) || a.kind.localeCompare(b.kind),
|
|
64
|
+
),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function dotString(value: string): string {
|
|
69
|
+
return `"${value
|
|
70
|
+
.replace(/\\/g, '\\\\')
|
|
71
|
+
.replace(/\r/g, '\\n')
|
|
72
|
+
.replace(/\n/g, '\\n')
|
|
73
|
+
.replace(/"/g, '\\"')}"`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function renderDotGraph(graph: AdrGraph): string {
|
|
77
|
+
const lines = ['digraph adr {', ' rankdir=LR;'];
|
|
78
|
+
for (const node of graph.nodes) {
|
|
79
|
+
lines.push(
|
|
80
|
+
` ${dotString(node.id)} [label=${dotString(`${node.id}: ${node.title}`)}, status=${dotString(
|
|
81
|
+
node.status,
|
|
82
|
+
)}];`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
for (const edge of graph.edges) {
|
|
86
|
+
lines.push(` ${dotString(edge.from)} -> ${dotString(edge.to)} [label=${dotString(edge.kind)}];`);
|
|
87
|
+
}
|
|
88
|
+
lines.push('}');
|
|
89
|
+
return `${lines.join('\n')}\n`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function renderJsonGraph(graph: AdrGraph): string {
|
|
93
|
+
return `${JSON.stringify(graph, null, 2)}\n`;
|
|
94
|
+
}
|