simt-dev 0.2.2 → 0.3.1
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 +100 -0
- package/README.md +129 -75
- package/bin/simt-dev.mjs +21 -2
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
- package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
- package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
- package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
- package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
- package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
- package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
- package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
- package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
- package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
- package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
- package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
- package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
- package/package.json +2 -1
- package/rules/core.md +1 -1
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +54 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +371 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/harness.mjs +156 -5
- package/src/commands/help.mjs +89 -1
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +60 -1
- package/src/commands/map.mjs +91 -0
- package/src/commands/plan.mjs +99 -0
- package/src/commands/repoindex.mjs +52 -0
- package/src/commands/shared.mjs +398 -0
- package/src/commands/sync.mjs +72 -3
- package/src/commands/tests.mjs +192 -0
- package/src/commands/tokens.mjs +61 -1
- package/src/commands/why.mjs +177 -0
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +1 -1
- package/src/hooks/dispatcher.mjs +15 -0
- package/src/hooks/shim.mjs +31 -0
- package/src/index/compact.mjs +228 -0
- package/src/index/extract.mjs +331 -0
- package/src/index/gate.mjs +101 -0
- package/src/index/govern.mjs +222 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +393 -0
- package/src/index/persist.mjs +172 -0
- package/src/index/store.mjs +301 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/ledger/index.mjs +112 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/server.mjs +37 -6
- package/src/mcp/tools.mjs +84 -0
- package/src/measure/context.mjs +184 -11
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/lock.mjs +93 -0
- package/src/projection/manifest.mjs +78 -3
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +452 -43
- package/src/projection/shared.mjs +428 -0
- package/src/projection/surfaces.mjs +22 -8
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +71 -0
- package/templates/plan/tracker.json +36 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// What governs a file (R25, ADR-093).
|
|
2
|
+
//
|
|
3
|
+
// A decision governs the files its Affects line names. The line is prose, so the
|
|
4
|
+
// only tokens that count are the ones in backticks, joined across the lines the
|
|
5
|
+
// paragraph wraps onto. A rule governs a file its scope matches. A skill, a gate
|
|
6
|
+
// and a lesson do the same with their own path list. Nothing here is stored: the
|
|
7
|
+
// answer is the records on disk.
|
|
8
|
+
|
|
9
|
+
const SIBLING = /^\*{0,2}[A-Z][\w-]*(?:[ \t]+[\w-]+){0,3}:\*{0,2}(?:[ \t]|$)/;
|
|
10
|
+
const HEADING = /^#{1,6}\s/;
|
|
11
|
+
const TOOL = /^(?:simt-dev|simt)\b/;
|
|
12
|
+
|
|
13
|
+
/** Backticked tokens of the Affects paragraph, in order. */
|
|
14
|
+
export function affectsTokens(text) {
|
|
15
|
+
const lines = String(text).split('\n');
|
|
16
|
+
const start = lines.findIndex((line) => /^\s*\*{0,2}Affects:\*{0,2}/.test(line));
|
|
17
|
+
if (start < 0) return [];
|
|
18
|
+
const parts = [lines[start].replace(/^\s*\*{0,2}Affects:\*{0,2}\s*/, '')];
|
|
19
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
20
|
+
const line = lines[i];
|
|
21
|
+
if (line.trim() === '' || SIBLING.test(line) || HEADING.test(line)) break;
|
|
22
|
+
parts.push(line);
|
|
23
|
+
}
|
|
24
|
+
return [...parts.join('\n').matchAll(/`([^`]+)`/g)].map((match) => match[1].trim()).filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Directories named src. A path written from inside one (`hooks/dispatcher.mjs`)
|
|
29
|
+
* resolves against each of them, after the repo root has already been tried.
|
|
30
|
+
* @param {Iterable<string>} files
|
|
31
|
+
*/
|
|
32
|
+
export function sourceRoots(files) {
|
|
33
|
+
const roots = new Set();
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
const parts = file.split('/');
|
|
36
|
+
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
37
|
+
if (parts[i] === 'src') roots.add(parts.slice(0, i + 1).join('/'));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return [...roots].sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* One Affects token.
|
|
45
|
+
*
|
|
46
|
+
* A prefixed command (`simt-dev init`, `simt hooks run`) is a command only when
|
|
47
|
+
* that command exists. The tool name on its own is not a command and not a path.
|
|
48
|
+
* Anything else is a path or a glob, at the repo root and then under each source
|
|
49
|
+
* root. A token that is none of those is unresolved.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} token
|
|
52
|
+
* @param {{files: Set<string>, commands: Set<string>, roots: string[]}} ctx
|
|
53
|
+
* @returns {{kind: 'command', id: string}|{kind: 'paths', paths: string[]}|{kind: 'unresolved', token: string}}
|
|
54
|
+
*/
|
|
55
|
+
export function resolveToken(token, { files, commands, roots }) {
|
|
56
|
+
const text = token.trim();
|
|
57
|
+
const command = commandOf(text, commands);
|
|
58
|
+
if (command !== undefined) {
|
|
59
|
+
return command === null ? { kind: 'unresolved', token: text } : { kind: 'command', id: command };
|
|
60
|
+
}
|
|
61
|
+
const paths = new Set();
|
|
62
|
+
for (const candidate of candidates(text, roots)) {
|
|
63
|
+
if (hasGlob(candidate)) {
|
|
64
|
+
for (const file of files) if (matchGlob(candidate, file)) paths.add(file);
|
|
65
|
+
} else if (files.has(candidate)) paths.add(candidate);
|
|
66
|
+
}
|
|
67
|
+
if (paths.size > 0) return { kind: 'paths', paths: [...paths].sort() };
|
|
68
|
+
return { kind: 'unresolved', token: text };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Decisions whose Affects line names this file, and tokens that named nothing. */
|
|
72
|
+
export function decisionHits(records, file, ctx) {
|
|
73
|
+
const hits = [];
|
|
74
|
+
const unresolved = [];
|
|
75
|
+
for (const record of records) {
|
|
76
|
+
let named = false;
|
|
77
|
+
for (const token of affectsTokens(record.text)) {
|
|
78
|
+
const resolved = resolveToken(token, ctx);
|
|
79
|
+
if (resolved.kind === 'unresolved') unresolved.push({ token: resolved.token, id: record.id });
|
|
80
|
+
else if (resolved.kind === 'paths' && resolved.paths.includes(file)) named = true;
|
|
81
|
+
}
|
|
82
|
+
if (named) hits.push({ kind: 'decision', id: record.id, detail: record.title });
|
|
83
|
+
}
|
|
84
|
+
return { hits, unresolved };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Every unresolved Affects token, one per record. */
|
|
88
|
+
export function unresolvedAffects(records, ctx) {
|
|
89
|
+
const found = [];
|
|
90
|
+
for (const record of records) {
|
|
91
|
+
for (const token of affectsTokens(record.text)) {
|
|
92
|
+
const resolved = resolveToken(token, ctx);
|
|
93
|
+
if (resolved.kind === 'unresolved') found.push({ token: resolved.token, id: record.id });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
found.sort((a, b) => cmp(a.token, b.token) || cmp(a.id, b.id));
|
|
97
|
+
return found;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Rules, skills, gates and lessons whose path list matches this file. */
|
|
101
|
+
export function scopeHits(file, { rules = [], skills = [], gates = [], lessons = [], commands = {} }) {
|
|
102
|
+
const hits = [];
|
|
103
|
+
for (const rule of rules) {
|
|
104
|
+
if (rule.paths.some((glob) => matchGlob(glob, file))) hits.push({ kind: 'rule', id: rule.id, detail: rule.detail });
|
|
105
|
+
}
|
|
106
|
+
for (const skill of skills) {
|
|
107
|
+
if (skill.paths.some((glob) => matchGlob(glob, file))) hits.push({ kind: 'skill', id: skill.id, detail: skill.detail });
|
|
108
|
+
}
|
|
109
|
+
for (const gate of gates) {
|
|
110
|
+
const declared = commands[gate.id];
|
|
111
|
+
const byExt = gate.extensions ? gate.extensions.has(extension(file)) : false;
|
|
112
|
+
const byCommand = typeof declared === 'string' && declared.split(/\s+/).includes(file);
|
|
113
|
+
if (byExt || byCommand) hits.push({ kind: 'gate', id: gate.id, detail: gate.detail });
|
|
114
|
+
}
|
|
115
|
+
for (const lesson of lessons) {
|
|
116
|
+
if (lesson.paths.some((glob) => matchGlob(glob, file))) hits.push({ kind: 'lesson', id: lesson.id, detail: lesson.detail });
|
|
117
|
+
}
|
|
118
|
+
return hits;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function sortHits(hits) {
|
|
122
|
+
const order = { decision: 0, rule: 1, skill: 2, gate: 3, lesson: 4 };
|
|
123
|
+
return [...hits].sort((a, b) => (order[a.kind] - order[b.kind]) || cmp(a.id, b.id));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Path globs from a `simt-scope` list, plus an applies entry that is itself a path. */
|
|
127
|
+
export function scopePaths(text) {
|
|
128
|
+
const front = frontmatter(text);
|
|
129
|
+
if (!front) return [];
|
|
130
|
+
const paths = quotedList(front, 'simt-scope');
|
|
131
|
+
for (const entry of quotedList(front, 'simt-applies')) {
|
|
132
|
+
if (entry.includes('/') || entry.includes('*') || entry.startsWith('.')) paths.push(entry);
|
|
133
|
+
}
|
|
134
|
+
return paths;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `paths` inside a lesson's trigger, either inline or as a YAML list. */
|
|
138
|
+
export function lessonPaths(text) {
|
|
139
|
+
const front = frontmatter(text);
|
|
140
|
+
if (!front) return [];
|
|
141
|
+
const inline = front.match(/paths:\s*\[([^\]]*)\]/);
|
|
142
|
+
if (inline) return [...inline[1].matchAll(/['"]([^'"]+)['"]/g)].map((match) => match[1]);
|
|
143
|
+
const paths = [];
|
|
144
|
+
let on = false;
|
|
145
|
+
for (const line of front.split('\n')) {
|
|
146
|
+
if (/paths:\s*$/.test(line)) {
|
|
147
|
+
on = true;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!on) continue;
|
|
151
|
+
const item = /^\s*-\s+['"]?([^'"]+?)['"]?\s*$/.exec(line);
|
|
152
|
+
if (!item) break;
|
|
153
|
+
paths.push(item[1]);
|
|
154
|
+
}
|
|
155
|
+
return paths;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function matchGlob(glob, file) {
|
|
159
|
+
return expandBraces(glob).some((one) => globRegExp(one).test(file));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function commandOf(token, commands) {
|
|
163
|
+
if (!TOOL.test(token)) return commands.has(token) ? token : undefined;
|
|
164
|
+
const rest = token.replace(TOOL, '').trim();
|
|
165
|
+
if (rest === '') return null;
|
|
166
|
+
return commands.has(rest) ? rest : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function candidates(token, roots) {
|
|
170
|
+
const norm = token.replaceAll('\\', '/').replace(/^\.\//, '');
|
|
171
|
+
return [norm, ...roots.map((root) => `${root}/${norm}`)];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function hasGlob(token) {
|
|
175
|
+
return token.includes('*') || token.includes('?') || token.includes('{');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function expandBraces(glob) {
|
|
179
|
+
const match = /\{([^{}]+)\}/.exec(glob);
|
|
180
|
+
if (!match) return [glob];
|
|
181
|
+
return match[1].split(',').flatMap((alt) => expandBraces(glob.replace(match[0], alt)));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function globRegExp(glob) {
|
|
185
|
+
let re = '^';
|
|
186
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
187
|
+
const c = glob[i];
|
|
188
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
189
|
+
i += 1;
|
|
190
|
+
if (glob[i + 1] === '/') {
|
|
191
|
+
re += '(?:.*/)?';
|
|
192
|
+
i += 1;
|
|
193
|
+
} else re += '.*';
|
|
194
|
+
} else if (c === '*') re += '[^/]*';
|
|
195
|
+
else if (c === '?') re += '[^/]';
|
|
196
|
+
else re += c.replace(/[\\^$+?.()|[\]{}]/g, '\\$&');
|
|
197
|
+
}
|
|
198
|
+
return new RegExp(`${re}$`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function extension(file) {
|
|
202
|
+
const base = file.slice(file.lastIndexOf('/') + 1);
|
|
203
|
+
const dot = base.lastIndexOf('.');
|
|
204
|
+
return dot < 0 ? '' : base.slice(dot);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function frontmatter(text) {
|
|
208
|
+
const match = /^---\n([\s\S]*?)\n---/.exec(String(text));
|
|
209
|
+
return match ? match[1] : '';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function quotedList(front, key) {
|
|
213
|
+
const match = new RegExp(`^${key}:\\s*\\[([^\\]]*)\\]`, 'm').exec(front);
|
|
214
|
+
if (!match) return [];
|
|
215
|
+
return [...match[1].matchAll(/['"]([^'"]+)['"]/g)].map((item) => item[1]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function cmp(a, b) {
|
|
219
|
+
if (a < b) return -1;
|
|
220
|
+
if (a > b) return 1;
|
|
221
|
+
return 0;
|
|
222
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Who a change reaches (R23, ADR-096).
|
|
2
|
+
//
|
|
3
|
+
// Reverse dependents, the tests that import them, the owners a CODEOWNERS
|
|
4
|
+
// line names, and another repository that references a contract this change
|
|
5
|
+
// touches. The walk stops at a fixed count: an unbounded report is not an
|
|
6
|
+
// answer a reader can use.
|
|
7
|
+
|
|
8
|
+
import { isTestPath } from '../bench/mine.mjs';
|
|
9
|
+
import { selectTests } from './gate.mjs';
|
|
10
|
+
|
|
11
|
+
const IMPACT_LIMIT = 64;
|
|
12
|
+
|
|
13
|
+
const CONTRACT_EXT = new Set(['json', 'yaml', 'yml']);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Non-test files that import `file`, directly or through other files, and the
|
|
17
|
+
* tests that import that closure. Past the limit, the rest is reported as
|
|
18
|
+
* truncated rather than dropped silently.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} file
|
|
21
|
+
* @param {{src: string, dst: string, kind?: string}[]} imports
|
|
22
|
+
* @param {number} [limit]
|
|
23
|
+
*/
|
|
24
|
+
export function dependents(file, imports, limit = IMPACT_LIMIT) {
|
|
25
|
+
const reverse = new Map();
|
|
26
|
+
for (const edge of imports) {
|
|
27
|
+
if (edge.kind && edge.kind !== 'import') continue;
|
|
28
|
+
const list = reverse.get(edge.dst);
|
|
29
|
+
if (list) list.push(edge.src);
|
|
30
|
+
else reverse.set(edge.dst, [edge.src]);
|
|
31
|
+
}
|
|
32
|
+
const importers = [];
|
|
33
|
+
const seen = new Set([file]);
|
|
34
|
+
const queue = [file];
|
|
35
|
+
let truncated = false;
|
|
36
|
+
while (queue.length > 0) {
|
|
37
|
+
const current = queue.shift();
|
|
38
|
+
const next = reverse.get(current);
|
|
39
|
+
if (!next) continue;
|
|
40
|
+
for (const src of next) {
|
|
41
|
+
if (seen.has(src)) continue;
|
|
42
|
+
seen.add(src);
|
|
43
|
+
if (isTestPath(src)) continue;
|
|
44
|
+
if (importers.length >= limit) {
|
|
45
|
+
truncated = true;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
importers.push(src);
|
|
49
|
+
queue.push(src);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
importers.sort();
|
|
53
|
+
const tests = selectTests([file], imports).filter((path) => path !== file);
|
|
54
|
+
const shown = tests.length > limit ? tests.slice(0, limit) : tests;
|
|
55
|
+
if (tests.length > limit) truncated = true;
|
|
56
|
+
return { importers, tests: shown, truncated };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Files that export `name`. A private symbol is not an export. */
|
|
60
|
+
export function filesExporting(name, symbols) {
|
|
61
|
+
const found = new Set();
|
|
62
|
+
for (const symbol of symbols) {
|
|
63
|
+
if (symbol.exported && symbol.name === name) found.add(symbol.path);
|
|
64
|
+
}
|
|
65
|
+
return [...found].sort();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The last matching CODEOWNERS pattern wins, which is the rule the file is
|
|
70
|
+
* read under. A pattern with no slash matches the file name in any directory.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} text
|
|
73
|
+
* @param {string} file
|
|
74
|
+
* @returns {string[]}
|
|
75
|
+
*/
|
|
76
|
+
export function ownersOf(text, file) {
|
|
77
|
+
let owners = [];
|
|
78
|
+
for (const raw of String(text).split('\n')) {
|
|
79
|
+
const line = raw.trim();
|
|
80
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
81
|
+
const parts = line.split(/\s+/);
|
|
82
|
+
const pattern = parts[0];
|
|
83
|
+
if (!pattern || !matchPattern(pattern, file)) continue;
|
|
84
|
+
owners = parts.slice(1).filter((part) => part.startsWith('@') || part.includes('@'));
|
|
85
|
+
}
|
|
86
|
+
return owners;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A path that is a contract by name: OpenAPI, or a JSON Schema file. */
|
|
90
|
+
export function isContractPath(file) {
|
|
91
|
+
const base = file.slice(file.lastIndexOf('/') + 1).toLowerCase();
|
|
92
|
+
if (base === 'openapi.json' || base === 'openapi.yaml' || base === 'openapi.yml') return true;
|
|
93
|
+
if (base.endsWith('.schema.json')) return true;
|
|
94
|
+
const dot = base.lastIndexOf('.');
|
|
95
|
+
if (dot <= 0) return false;
|
|
96
|
+
return CONTRACT_EXT.has(base.slice(dot + 1)) && base.includes('schema');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Contracts this change can reach: the file itself, a dependent, or a contract
|
|
101
|
+
* whose text names the file or the export.
|
|
102
|
+
*
|
|
103
|
+
* @param {{path: string, text: string}[]} contracts
|
|
104
|
+
* @param {{file: string, symbol: string, importers: string[]}} target
|
|
105
|
+
*/
|
|
106
|
+
export function relatedContracts(contracts, { file, symbol, importers }) {
|
|
107
|
+
const reached = new Set([file, ...importers]);
|
|
108
|
+
const related = [];
|
|
109
|
+
for (const contract of contracts) {
|
|
110
|
+
if (reached.has(contract.path) || names(contract.text, file, symbol)) related.push(contract.path);
|
|
111
|
+
}
|
|
112
|
+
related.sort();
|
|
113
|
+
return related;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Files in other repositories whose text contains a contract path. The path
|
|
118
|
+
* has to include a directory, so a bare filename is not a reference.
|
|
119
|
+
*
|
|
120
|
+
* @param {string[]} contracts repo-relative contract paths
|
|
121
|
+
* @param {string} repoName the repository that owns the contracts
|
|
122
|
+
* @param {{repo: string, path: string, text: string}[]} files
|
|
123
|
+
* @param {number} [limit]
|
|
124
|
+
*/
|
|
125
|
+
export function contractReferences(contracts, repoName, files, limit = IMPACT_LIMIT) {
|
|
126
|
+
const needles = [];
|
|
127
|
+
for (const contract of contracts) {
|
|
128
|
+
if (!contract.includes('/')) continue;
|
|
129
|
+
needles.push({ contract, needle: contract });
|
|
130
|
+
if (repoName) needles.push({ contract, needle: `${repoName}/${contract}` });
|
|
131
|
+
}
|
|
132
|
+
const found = [];
|
|
133
|
+
let truncated = false;
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
for (const item of needles) {
|
|
137
|
+
if (!file.text.includes(item.needle)) continue;
|
|
138
|
+
const key = `${file.repo}\0${file.path}\0${item.contract}`;
|
|
139
|
+
if (seen.has(key)) continue;
|
|
140
|
+
seen.add(key);
|
|
141
|
+
if (found.length >= limit) {
|
|
142
|
+
truncated = true;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
found.push({ repo: file.repo, path: file.path, contract: item.contract });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
found.sort((a, b) => cmp(a.repo, b.repo) || cmp(a.path, b.path) || cmp(a.contract, b.contract));
|
|
149
|
+
return { references: found, truncated };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function names(text, file, symbol) {
|
|
153
|
+
if (file && text.includes(file)) return true;
|
|
154
|
+
if (!symbol) return false;
|
|
155
|
+
const escaped = symbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
156
|
+
return new RegExp(`\\b${escaped}\\b`).test(text);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function matchPattern(pattern, file) {
|
|
160
|
+
let pat = pattern.startsWith('/') ? pattern.slice(1) : pattern;
|
|
161
|
+
const directory = pat.endsWith('/');
|
|
162
|
+
if (directory) pat = pat.slice(0, -1);
|
|
163
|
+
if (pat === '') return false;
|
|
164
|
+
if (!pat.includes('/')) {
|
|
165
|
+
if (directory) return file === pat || file.startsWith(`${pat}/`) || file.split('/').includes(pat);
|
|
166
|
+
const base = file.slice(file.lastIndexOf('/') + 1);
|
|
167
|
+
return glob(pat, base);
|
|
168
|
+
}
|
|
169
|
+
if (directory) return file === pat || file.startsWith(`${pat}/`);
|
|
170
|
+
return glob(pat, file);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function glob(pattern, text) {
|
|
174
|
+
const body = pattern
|
|
175
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
176
|
+
.replaceAll('**', '\u0000')
|
|
177
|
+
.replaceAll('*', '[^/]*')
|
|
178
|
+
.replaceAll('\u0000', '.*');
|
|
179
|
+
return new RegExp(`^${body}$`).test(text);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function cmp(a, b) {
|
|
183
|
+
if (a < b) return -1;
|
|
184
|
+
if (a > b) return 1;
|
|
185
|
+
return 0;
|
|
186
|
+
}
|