@esneiderbravo/speclaw 0.3.9 → 0.3.11
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/dist/cli/commands/query.js +120 -5
- package/dist/cli/commands/update.js +17 -0
- package/dist/cli/index.js +10 -4
- package/dist/modules/compass/affected-config.js +238 -0
- package/dist/modules/compass/affected.js +249 -0
- package/dist/modules/compass/db.js +23 -4
- package/dist/modules/compass/extract.js +44 -0
- package/dist/modules/compass/git-history-cache.js +19 -2
- package/dist/modules/compass/hotspots.js +230 -0
- package/dist/modules/compass/indexer.js +120 -6
- package/dist/modules/compass/languages.js +39 -0
- package/dist/modules/compass/query.js +255 -30
- package/dist/modules/compass/register.js +49 -1
- package/dist/shared/exposure.js +3 -0
- package/dist/shared/git-history.js +85 -5
- package/package.json +1 -1
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { explore, search, recall, impact, trace } from "../../modules/compass/query.js";
|
|
2
|
+
import { affectedTests } from "../../modules/compass/affected.js";
|
|
3
|
+
import { hotspots, coupling } from "../../modules/compass/hotspots.js";
|
|
4
|
+
import { list } from "../lib/args.js";
|
|
2
5
|
import { ui } from "../lib/ui.js";
|
|
3
6
|
/**
|
|
4
7
|
* Run a Compass query from the shell — the same surface agents call via MCP.
|
|
5
8
|
*
|
|
6
|
-
* @param cmd -
|
|
7
|
-
*
|
|
9
|
+
* @param cmd - Query verb: `explore`, `search`, `recall`, `impact`, `trace`,
|
|
10
|
+
* `affected-tests`, `hotspots`, or `coupling`.
|
|
11
|
+
* @param flags - Parsed flags supplying positional args and options in `_`.
|
|
8
12
|
* @throws Exits the process with code 1 on missing arguments or query errors.
|
|
9
13
|
*/
|
|
10
14
|
export async function runQuery(cmd, flags) {
|
|
11
15
|
const cwd = process.cwd();
|
|
12
16
|
const args = flags._;
|
|
17
|
+
const asJson = Boolean(flags.json);
|
|
13
18
|
try {
|
|
14
19
|
switch (cmd) {
|
|
15
20
|
case "explore": {
|
|
@@ -41,9 +46,119 @@ export async function runQuery(cmd, flags) {
|
|
|
41
46
|
return;
|
|
42
47
|
}
|
|
43
48
|
case "impact": {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
const fileList = list(flags.file);
|
|
50
|
+
if (!args[0] && fileList.length === 0) {
|
|
51
|
+
need(undefined, "impact <node> | impact --file <path>");
|
|
52
|
+
}
|
|
53
|
+
const format = flags.flat ? "flat" : "grouped";
|
|
54
|
+
const result = impact(cwd, {
|
|
55
|
+
symbol: args[0],
|
|
56
|
+
files: fileList.length ? fileList : undefined,
|
|
57
|
+
maxDepth: flags.depth ? Number(flags.depth) : 4,
|
|
58
|
+
format,
|
|
59
|
+
target: typeof flags.target === "string" ? flags.target : "any",
|
|
60
|
+
});
|
|
61
|
+
if (asJson) {
|
|
62
|
+
console.log(JSON.stringify(result, null, 2));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (result.global) {
|
|
66
|
+
ui.heading(`Blast radius: repo-wide`);
|
|
67
|
+
ui.warn(result.global.reason);
|
|
68
|
+
result.warnings.forEach((w) => ui.warn(w));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
ui.heading(`Blast radius: ${result.totals.nodes} node(s) in ${result.totals.modules} module(s)` +
|
|
72
|
+
(result.limits.truncated ? " (truncated)" : ""));
|
|
73
|
+
if (result.target.kind === "symbol" && result.target.definitions.length > 1) {
|
|
74
|
+
ui.warn(`"${result.target.symbol}" is defined in ${result.target.definitions.length} places; impact is the union`);
|
|
75
|
+
}
|
|
76
|
+
if (format === "flat" && result.nodes) {
|
|
77
|
+
result.nodes.forEach((n) => ui.info(`depth ${n.depth} [${n.resolution}]: ${n.name} (${n.file}:${n.line})`));
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
for (const m of result.modules) {
|
|
81
|
+
ui.heading(`${m.module} — ${m.nodes} node(s), ${m.files} file(s), min depth ${m.minDepth}`);
|
|
82
|
+
m.top.forEach((n) => ui.info(`depth ${n.depth} [${n.resolution}]: ${n.name} (${n.file}:${n.line})`));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (result.resolution.byName > 0) {
|
|
86
|
+
ui.warn(`${result.resolution.byName} result(s) resolved by name (possible collisions)`);
|
|
87
|
+
}
|
|
88
|
+
result.warnings.forEach((w) => ui.warn(w));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
case "affected-tests": {
|
|
92
|
+
const files = list(flags.file);
|
|
93
|
+
const fromDiff = typeof flags["from-diff"] === "string"
|
|
94
|
+
? flags["from-diff"]
|
|
95
|
+
: flags["from-diff"] === true
|
|
96
|
+
? "HEAD"
|
|
97
|
+
: undefined;
|
|
98
|
+
if (files.length === 0 && !fromDiff && !args[0]) {
|
|
99
|
+
need(undefined, "affected-tests --file <path> | --from-diff <ref>");
|
|
100
|
+
}
|
|
101
|
+
const result = affectedTests(cwd, {
|
|
102
|
+
files: files.length ? files : undefined,
|
|
103
|
+
symbols: args[0] ? [args[0]] : undefined,
|
|
104
|
+
fromDiff,
|
|
105
|
+
});
|
|
106
|
+
if (asJson) {
|
|
107
|
+
console.log(JSON.stringify(result, null, 2));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
ui.heading(`Affected tests (${result.mode}): ${result.tests.length} file(s) — skipped ${result.skipped.files} (${result.skipped.percent}%)`);
|
|
111
|
+
ui.info(result.reason);
|
|
112
|
+
result.tests.forEach((t) => ui.info(t.file));
|
|
113
|
+
ui.heading("Command");
|
|
114
|
+
console.log(result.command);
|
|
115
|
+
result.warnings.forEach((w) => ui.warn(w));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
case "hotspots": {
|
|
119
|
+
const sortRaw = typeof flags.sort === "string" ? flags.sort : undefined;
|
|
120
|
+
const sortBy = sortRaw === "churn" || sortRaw === "complexity" || sortRaw === "combined"
|
|
121
|
+
? sortRaw
|
|
122
|
+
: "combined";
|
|
123
|
+
const result = hotspots(cwd, {
|
|
124
|
+
days: flags.days ? Number(flags.days) : undefined,
|
|
125
|
+
since: typeof flags.since === "string" ? flags.since : undefined,
|
|
126
|
+
sortBy,
|
|
127
|
+
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
128
|
+
});
|
|
129
|
+
if (asJson) {
|
|
130
|
+
console.log(JSON.stringify(result, null, 2));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
ui.heading(`Hotspots (${result.window.label}, sort=${result.sortBy}): ${result.hotspots.length}`);
|
|
134
|
+
for (const h of result.hotspots) {
|
|
135
|
+
const health = h.health
|
|
136
|
+
? `branches=${h.health.worstBranches} nest=${h.health.worstNesting} loc=${h.health.worstLoc}`
|
|
137
|
+
: "health=n/a";
|
|
138
|
+
ui.info(`${h.file} commits=${h.activity.commits} authors=${h.activity.authors} ${health}`);
|
|
139
|
+
}
|
|
140
|
+
result.warnings.forEach((w) => ui.warn(w));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
case "coupling": {
|
|
144
|
+
const file = need(args[0], "coupling <file>");
|
|
145
|
+
const result = coupling(cwd, file, {
|
|
146
|
+
days: flags.days ? Number(flags.days) : undefined,
|
|
147
|
+
since: typeof flags.since === "string" ? flags.since : undefined,
|
|
148
|
+
minShared: flags["min-shared"] ? Number(flags["min-shared"]) : undefined,
|
|
149
|
+
maxFilesPerCommit: flags["max-files"] ? Number(flags["max-files"]) : undefined,
|
|
150
|
+
limit: flags.limit ? Number(flags.limit) : undefined,
|
|
151
|
+
});
|
|
152
|
+
if (asJson) {
|
|
153
|
+
console.log(JSON.stringify(result, null, 2));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
ui.heading(`Coupling for ${result.file} (${result.window.label}): ${result.partners.length} partner(s)`);
|
|
157
|
+
ui.info(`scanned=${result.diagnostics.commitsScanned} skippedTooLarge=${result.diagnostics.skippedTooLarge}`);
|
|
158
|
+
for (const p of result.partners) {
|
|
159
|
+
ui.info(`${p.file} both=${p.both} strength=${p.strength.toFixed(3)} in_graph=${p.inGraph} isTestPair=${p.isTestPair}`);
|
|
160
|
+
}
|
|
161
|
+
result.warnings.forEach((w) => ui.warn(w));
|
|
47
162
|
return;
|
|
48
163
|
}
|
|
49
164
|
case "trace": {
|
|
@@ -108,6 +108,23 @@ const MIGRATIONS = [
|
|
|
108
108
|
"reindex with `speclaw index`, then `speclaw drift --reseal` once to photograph current bodies.\n" +
|
|
109
109
|
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
110
110
|
},
|
|
111
|
+
{
|
|
112
|
+
version: "0.3.10",
|
|
113
|
+
describe: "Impact v2 + affected-tests: schema 7, grouped blast radius",
|
|
114
|
+
agentPrompt: "- Mention `compass_impact` (grouped by default; `format: flat` escape hatch) and " +
|
|
115
|
+
"`compass_affected_tests` / `speclaw affected-tests --from-diff`. Compass schema is now 7 " +
|
|
116
|
+
"(`files.is_test`, `files.module`) — reindex with `speclaw index`. Optional " +
|
|
117
|
+
"`.speclaw/affected.json` overrides globals/test globs.\n" +
|
|
118
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
version: "0.3.11",
|
|
122
|
+
describe: "Hotspots + coupling: schema 8 node_metrics, 90d activity window",
|
|
123
|
+
agentPrompt: "- Mention `compass_hotspots` / `speclaw hotspots` and `compass_coupling` / `speclaw coupling` " +
|
|
124
|
+
"(activity × AST health; Jaccard strength + in_graph + isTestPair). Compass schema is now 8 " +
|
|
125
|
+
"(`node_metrics`) — reindex with `speclaw index`. Default history window is 90 days.\n" +
|
|
126
|
+
"- Preserve all project-specific wording; only apply these speclaw-authored changes.",
|
|
127
|
+
},
|
|
111
128
|
];
|
|
112
129
|
/**
|
|
113
130
|
* Update speclaw and bring the current project up to date without a full re-init:
|
package/dist/cli/index.js
CHANGED
|
@@ -23,7 +23,10 @@ Compass (code intelligence — the same surface agents use via MCP)
|
|
|
23
23
|
explore <node> A node's source + callers/callees
|
|
24
24
|
search <query> Find nodes by name/keyword
|
|
25
25
|
recall "<query>" Find code by meaning (semantic)
|
|
26
|
-
impact <node> Blast radius
|
|
26
|
+
impact <node> Blast radius (grouped by module; --flat / --json)
|
|
27
|
+
affected-tests Tests affected by a change (--file / --from-diff / --json)
|
|
28
|
+
hotspots Rank files by recent churn × AST complexity (--json / --sort)
|
|
29
|
+
coupling <file> Temporal co-change partners for a file (--json)
|
|
27
30
|
trace <from> <to> A call path between two nodes
|
|
28
31
|
visualize [node] Interactive HTML graph → .speclaw/graph.html
|
|
29
32
|
|
|
@@ -50,9 +53,9 @@ Other
|
|
|
50
53
|
// Commands that open with the one-line branded header. These are the
|
|
51
54
|
// interactive, human-facing commands whose stdout is prose. Deliberately
|
|
52
55
|
// excluded: `version`/`--version`/`-v` (bare scriptable value), the Compass
|
|
53
|
-
// query family (`explore`/`search`/`recall`/`impact`/`trace
|
|
54
|
-
// output), `mcp` (a long-running stdio
|
|
55
|
-
// the fuller `banner()`).
|
|
56
|
+
// query family (`explore`/`search`/`recall`/`impact`/`trace`/`affected-tests`/
|
|
57
|
+
// `hotspots`/`coupling`, machine-consumed output), `mcp` (a long-running stdio
|
|
58
|
+
// server), and `init` (already opens with the fuller `banner()`).
|
|
56
59
|
const HEADER_COMMANDS = new Set([
|
|
57
60
|
undefined,
|
|
58
61
|
"help",
|
|
@@ -126,6 +129,9 @@ async function dispatch(cmd, flags) {
|
|
|
126
129
|
case "recall":
|
|
127
130
|
case "impact":
|
|
128
131
|
case "trace":
|
|
132
|
+
case "affected-tests":
|
|
133
|
+
case "hotspots":
|
|
134
|
+
case "coupling":
|
|
129
135
|
return (await import("./commands/query.js")).runQuery(cmd, flags);
|
|
130
136
|
case "visualize":
|
|
131
137
|
return (await import("./commands/visualize.js")).runVisualize(flags);
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional `.speclaw/affected.json` plus embedded defaults for global files,
|
|
3
|
+
* test globs, named targets, and ignores used by impact / affected-tests.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
const DEFAULT_TARGETS = {
|
|
8
|
+
build: {
|
|
9
|
+
include: ["src/**"],
|
|
10
|
+
exclude: ["**/*.{test,spec}.{ts,tsx,js,jsx,mts,cts}", "test/**", "tests/**"],
|
|
11
|
+
},
|
|
12
|
+
test: {
|
|
13
|
+
include: [
|
|
14
|
+
"src/**",
|
|
15
|
+
"test/**",
|
|
16
|
+
"tests/**",
|
|
17
|
+
"**/*.{test,spec}.{ts,tsx,js,jsx}",
|
|
18
|
+
"**/conftest.py",
|
|
19
|
+
],
|
|
20
|
+
exclude: [],
|
|
21
|
+
},
|
|
22
|
+
lint: {
|
|
23
|
+
include: ["**/*.{ts,tsx,js,jsx,py}", ".eslintrc*", "eslint.config.*", "ruff.toml"],
|
|
24
|
+
exclude: [],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
/** Embedded defaults when `.speclaw/affected.json` is absent. */
|
|
28
|
+
export const DEFAULT_AFFECTED_CONFIG = {
|
|
29
|
+
version: 1,
|
|
30
|
+
globalFiles: [
|
|
31
|
+
"tsconfig*.json",
|
|
32
|
+
"package.json",
|
|
33
|
+
"package-lock.json",
|
|
34
|
+
"pnpm-lock.yaml",
|
|
35
|
+
"yarn.lock",
|
|
36
|
+
"LAWS.md",
|
|
37
|
+
"lawbook/config.yaml",
|
|
38
|
+
".speclaw/rules.json",
|
|
39
|
+
"**/*.config.{ts,js,mjs,cjs}",
|
|
40
|
+
],
|
|
41
|
+
testGlobs: [
|
|
42
|
+
"test/**",
|
|
43
|
+
"tests/**",
|
|
44
|
+
"**/*.test.*",
|
|
45
|
+
"**/*.spec.*",
|
|
46
|
+
"**/conftest.py",
|
|
47
|
+
"**/test_*.py",
|
|
48
|
+
],
|
|
49
|
+
targets: DEFAULT_TARGETS,
|
|
50
|
+
ignore: [
|
|
51
|
+
"node_modules/**",
|
|
52
|
+
"dist/**",
|
|
53
|
+
"dist-test/**",
|
|
54
|
+
"vendor/**",
|
|
55
|
+
"**/generated/**",
|
|
56
|
+
".speclaw/**",
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Glob match supporting `**`, `*`, and simple `{a,b}` brace lists.
|
|
61
|
+
*
|
|
62
|
+
* @param relPath - Project-relative path (either slash style).
|
|
63
|
+
* @param pattern - Glob pattern.
|
|
64
|
+
*/
|
|
65
|
+
export function matchGlob(relPath, pattern) {
|
|
66
|
+
const norm = relPath.split("\\").join("/");
|
|
67
|
+
for (const expanded of expandBraces(pattern.split("\\").join("/"))) {
|
|
68
|
+
if (globToRegExp(expanded).test(norm))
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
function expandBraces(pattern) {
|
|
74
|
+
const m = pattern.match(/\{([^{}]+)\}/);
|
|
75
|
+
if (!m || m.index === undefined)
|
|
76
|
+
return [pattern];
|
|
77
|
+
const before = pattern.slice(0, m.index);
|
|
78
|
+
const after = pattern.slice(m.index + m[0].length);
|
|
79
|
+
const alts = m[1].split(",");
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const alt of alts) {
|
|
82
|
+
out.push(...expandBraces(before + alt + after));
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function globToRegExp(pattern) {
|
|
87
|
+
let i = 0;
|
|
88
|
+
let re = "^";
|
|
89
|
+
while (i < pattern.length) {
|
|
90
|
+
if (pattern.startsWith("**/", i) || (pattern.startsWith("**", i) && i + 2 === pattern.length)) {
|
|
91
|
+
re += ".*";
|
|
92
|
+
i += pattern.startsWith("**/", i) ? 3 : 2;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (pattern[i] === "*") {
|
|
96
|
+
re += "[^/]*";
|
|
97
|
+
i++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const ch = pattern[i];
|
|
101
|
+
if (/[.+^${}()|[\]\\]/.test(ch))
|
|
102
|
+
re += `\\${ch}`;
|
|
103
|
+
else
|
|
104
|
+
re += ch;
|
|
105
|
+
i++;
|
|
106
|
+
}
|
|
107
|
+
re += "$";
|
|
108
|
+
return new RegExp(re);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Whether a relative path matches any of the given globs.
|
|
112
|
+
*
|
|
113
|
+
* @param relPath - Project-relative path.
|
|
114
|
+
* @param globs - Patterns to try.
|
|
115
|
+
*/
|
|
116
|
+
export function matchesAny(relPath, globs) {
|
|
117
|
+
return globs.some((g) => matchGlob(relPath, g));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Infer the module bucket for grouping (nearest package root or first two path segments).
|
|
121
|
+
*
|
|
122
|
+
* @param relPath - Project-relative file path.
|
|
123
|
+
*/
|
|
124
|
+
export function inferModule(relPath) {
|
|
125
|
+
const norm = relPath.split("\\").join("/");
|
|
126
|
+
const parts = norm.split("/").filter(Boolean);
|
|
127
|
+
if (parts.length === 0)
|
|
128
|
+
return ".";
|
|
129
|
+
if (parts.length === 1)
|
|
130
|
+
return parts[0];
|
|
131
|
+
// Prefer src/<area>, test/<area>, packages/<name>, …
|
|
132
|
+
return parts.slice(0, 2).join("/");
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Whether a path looks like a test file under the given test globs.
|
|
136
|
+
*
|
|
137
|
+
* @param relPath - Project-relative path.
|
|
138
|
+
* @param testGlobs - Glob list (defaults from config).
|
|
139
|
+
*/
|
|
140
|
+
export function isTestPath(relPath, testGlobs = DEFAULT_AFFECTED_CONFIG.testGlobs) {
|
|
141
|
+
return matchesAny(relPath, testGlobs);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Load and validate `.speclaw/affected.json`, or return embedded defaults.
|
|
145
|
+
*
|
|
146
|
+
* @param projectPath - Absolute project root.
|
|
147
|
+
* @returns The effective config.
|
|
148
|
+
* @throws If the file exists but is malformed or has the wrong version shape.
|
|
149
|
+
*/
|
|
150
|
+
export function loadAffectedConfig(projectPath) {
|
|
151
|
+
const p = path.join(projectPath, ".speclaw", "affected.json");
|
|
152
|
+
if (!fs.existsSync(p))
|
|
153
|
+
return { ...DEFAULT_AFFECTED_CONFIG, targets: { ...DEFAULT_TARGETS } };
|
|
154
|
+
let raw;
|
|
155
|
+
try {
|
|
156
|
+
raw = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
throw new Error(`Invalid .speclaw/affected.json: ${err.message}`, { cause: err });
|
|
160
|
+
}
|
|
161
|
+
if (!raw || typeof raw !== "object") {
|
|
162
|
+
throw new Error("Invalid .speclaw/affected.json: expected a JSON object");
|
|
163
|
+
}
|
|
164
|
+
const doc = raw;
|
|
165
|
+
if (doc.version !== 1 && doc.version !== undefined) {
|
|
166
|
+
throw new Error(`Invalid .speclaw/affected.json: unsupported version ${String(doc.version)}`);
|
|
167
|
+
}
|
|
168
|
+
const cfg = {
|
|
169
|
+
version: 1,
|
|
170
|
+
globalFiles: asStringArray(doc.globalFiles, DEFAULT_AFFECTED_CONFIG.globalFiles),
|
|
171
|
+
testGlobs: asStringArray(doc.testGlobs, DEFAULT_AFFECTED_CONFIG.testGlobs),
|
|
172
|
+
ignore: asStringArray(doc.ignore, DEFAULT_AFFECTED_CONFIG.ignore),
|
|
173
|
+
targets: { ...DEFAULT_TARGETS },
|
|
174
|
+
};
|
|
175
|
+
if (doc.targets && typeof doc.targets === "object") {
|
|
176
|
+
const t = doc.targets;
|
|
177
|
+
for (const name of ["build", "test", "lint"]) {
|
|
178
|
+
const entry = t[name];
|
|
179
|
+
if (!entry || typeof entry !== "object")
|
|
180
|
+
continue;
|
|
181
|
+
const e = entry;
|
|
182
|
+
cfg.targets[name] = {
|
|
183
|
+
include: asStringArray(e.include, DEFAULT_TARGETS[name].include),
|
|
184
|
+
exclude: asStringArray(e.exclude, DEFAULT_TARGETS[name].exclude),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return cfg;
|
|
189
|
+
}
|
|
190
|
+
function asStringArray(value, fallback) {
|
|
191
|
+
if (!Array.isArray(value))
|
|
192
|
+
return [...fallback];
|
|
193
|
+
const out = value.filter((v) => typeof v === "string" && v.length > 0);
|
|
194
|
+
return out.length > 0 ? out : [...fallback];
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Filter changed paths for a named target (Nx-style named inputs).
|
|
198
|
+
*
|
|
199
|
+
* @param files - Candidate relative paths.
|
|
200
|
+
* @param target - Target name; `any` keeps all non-ignored paths.
|
|
201
|
+
* @param cfg - Affected config.
|
|
202
|
+
*/
|
|
203
|
+
export function filterFilesForTarget(files, target, cfg) {
|
|
204
|
+
const warnings = [];
|
|
205
|
+
const notIgnored = files.filter((f) => !matchesAny(f, cfg.ignore));
|
|
206
|
+
if (target === "any")
|
|
207
|
+
return { included: notIgnored, warnings };
|
|
208
|
+
const spec = cfg.targets[target];
|
|
209
|
+
const included = notIgnored.filter((f) => {
|
|
210
|
+
if (spec.exclude.some((g) => matchGlob(f, g)))
|
|
211
|
+
return false;
|
|
212
|
+
return spec.include.some((g) => matchGlob(f, g));
|
|
213
|
+
});
|
|
214
|
+
if (included.length === 0 && notIgnored.length > 0) {
|
|
215
|
+
warnings.push(`Change only affects other targets; empty for target "${target}" (${notIgnored.length} path(s) filtered out)`);
|
|
216
|
+
}
|
|
217
|
+
return { included, warnings };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Match changed paths against globalFiles globs.
|
|
221
|
+
*
|
|
222
|
+
* @param files - Relative paths.
|
|
223
|
+
* @param cfg - Affected config.
|
|
224
|
+
*/
|
|
225
|
+
export function matchGlobalFiles(files, cfg) {
|
|
226
|
+
const patterns = new Set();
|
|
227
|
+
const matched = [];
|
|
228
|
+
for (const f of files) {
|
|
229
|
+
for (const g of cfg.globalFiles) {
|
|
230
|
+
if (matchGlob(f, g)) {
|
|
231
|
+
matched.push(f);
|
|
232
|
+
patterns.add(g);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { matched, patterns: [...patterns] };
|
|
238
|
+
}
|