flagrm 0.2.1 → 0.4.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/CHANGELOG.md +129 -0
- package/README.md +70 -31
- package/dist/adapters/angular/component-files.js +41 -0
- package/dist/adapters/angular/flow.js +121 -0
- package/dist/adapters/angular/holders.js +194 -0
- package/dist/adapters/angular/index.js +20 -1
- package/dist/adapters/angular/inputs.js +181 -0
- package/dist/adapters/dotnet/index.js +11 -1
- package/dist/cli.js +62 -21
- package/dist/core/baseline.js +98 -45
- package/dist/core/change-filter.js +68 -0
- package/dist/core/config-snapshot.js +36 -6
- package/dist/core/config.js +23 -7
- package/dist/core/detect.js +2 -2
- package/dist/core/doctor.js +51 -7
- package/dist/core/environment.js +25 -3
- package/dist/core/git.js +80 -16
- package/dist/core/hook.js +9 -6
- package/dist/core/install.js +26 -11
- package/dist/core/inventory.js +3 -1
- package/dist/core/markdown.js +4 -0
- package/dist/core/name-owner.js +179 -0
- package/dist/core/report.js +13 -0
- package/dist/core/run-cache.js +137 -0
- package/dist/core/scope-files.js +14 -0
- package/dist/core/scope.js +11 -2
- package/dist/core/test-runner.js +136 -0
- package/dist/core/types.js +9 -4
- package/dist/core/verify/commands.js +170 -29
- package/dist/core/verify/coverage.js +92 -0
- package/dist/core/verify/dead-code.js +9 -2
- package/dist/core/verify/index.js +42 -8
- package/dist/core/verify/log-results.js +127 -0
- package/dist/core/verify/orphans.js +175 -0
- package/dist/core/verify/removed-tests.js +90 -7
- package/dist/core/verify/test-results.js +56 -7
- package/package.json +3 -1
- package/skills/flagrm-remove/SKILL.md +31 -11
- package/skills/flagrm-remove/batch.md +38 -0
- package/skills/flagrm-verify/SKILL.md +12 -6
package/dist/core/environment.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tell a machine setup problem apart from broken code in a failed build or
|
|
3
3
|
* test log: a missing .NET SDK, a tool a build step calls that isn't
|
|
4
|
-
* installed, Docker not running for Testcontainers
|
|
5
|
-
* that says what to fix.
|
|
4
|
+
* installed, Docker not running for Testcontainers, a package script or
|
|
5
|
+
* ESLint config that doesn't exist. Each becomes one line that says what to fix.
|
|
6
6
|
*/
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
@@ -12,7 +12,13 @@ import { relativePath } from "./util.js";
|
|
|
12
12
|
* it shows none. `root` is the project root: paths are reported relative to it.
|
|
13
13
|
*/
|
|
14
14
|
export function environmentProblem(log, root) {
|
|
15
|
-
const problems = [
|
|
15
|
+
const problems = [
|
|
16
|
+
missingSdk(log, root),
|
|
17
|
+
...missingCommands(log, root),
|
|
18
|
+
dockerUnavailable(log),
|
|
19
|
+
missingScript(log),
|
|
20
|
+
eslintBroken(log),
|
|
21
|
+
].filter(Boolean);
|
|
16
22
|
return problems.length ? problems.join("; ") : undefined;
|
|
17
23
|
}
|
|
18
24
|
/**
|
|
@@ -77,3 +83,19 @@ function dockerUnavailable(log) {
|
|
|
77
83
|
return undefined;
|
|
78
84
|
return "Docker is not running (Testcontainers tests need it): start Docker or leave those tests out of `test`";
|
|
79
85
|
}
|
|
86
|
+
/** npm and pnpm print `Missing script: "lint"`, Yarn 1 `Command "lint" not found`, Yarn 2+ `Couldn't find a script named "lint"`. */
|
|
87
|
+
function missingScript(log) {
|
|
88
|
+
const name = /\bMissing script: "?([^"\s]+)"?/.exec(log)?.[1] ??
|
|
89
|
+
/\berror Command "([^"]+)" not found/.exec(log)?.[1] ??
|
|
90
|
+
/Couldn't find a script named "([^"]+)"/.exec(log)?.[1];
|
|
91
|
+
if (!name)
|
|
92
|
+
return undefined;
|
|
93
|
+
return `package.json has no "${name}" script: add it or change the command in the config`;
|
|
94
|
+
}
|
|
95
|
+
/** ESLint that can't find its config or crashes exits 2 before it lints anything. */
|
|
96
|
+
function eslintBroken(log) {
|
|
97
|
+
if (!/ESLint couldn't find (?:a configuration file|an eslint\.config)|Oops! Something went wrong! :\(/.test(log)) {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
return "ESLint could not run (no config found, or it crashed): fix its setup or change the command in the config";
|
|
101
|
+
}
|
package/dist/core/git.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* to the config root (`cwd`), and flagrm's own `.flagrm/` directory is ignored.
|
|
4
4
|
*/
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
6
7
|
import fs from "node:fs";
|
|
7
8
|
import os from "node:os";
|
|
8
9
|
import path from "node:path";
|
|
@@ -26,32 +27,70 @@ export function gitState(cwd) {
|
|
|
26
27
|
const dirtyFiles = changedFilesSince(cwd, "HEAD") ?? [];
|
|
27
28
|
return { sha: head.stdout.trim(), dirty: dirtyFiles.length > 0, dirtyFiles };
|
|
28
29
|
}
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
function hashObjects(cwd, files) {
|
|
31
|
+
const result = spawnSync("git", ["hash-object", "--stdin-paths"], {
|
|
32
|
+
cwd,
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
input: `${files.join("\n")}\n`,
|
|
35
|
+
});
|
|
36
|
+
return result.status === 0 ? (result.stdout ?? "").split("\n") : undefined;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Each file's git blob id as it is in the working tree (null when it doesn't
|
|
40
|
+
* exist), so a later run can tell whether it changed since. A file git can't
|
|
41
|
+
* hash (a submodule directory) gets an id of its own each time, so it always
|
|
42
|
+
* counts as changed. Paths are relative to `cwd`.
|
|
43
|
+
*/
|
|
44
|
+
export function blobIds(cwd, files) {
|
|
45
|
+
const ids = {};
|
|
46
|
+
const existing = files.filter((rel) => fs.existsSync(path.join(cwd, rel)));
|
|
47
|
+
for (const rel of files)
|
|
48
|
+
ids[rel] = null;
|
|
49
|
+
if (existing.length === 0)
|
|
50
|
+
return ids;
|
|
51
|
+
// One git call for all files; when it fails, one per file, so a single unhashable path doesn't spoil the rest.
|
|
52
|
+
const out = hashObjects(cwd, existing);
|
|
53
|
+
existing.forEach((rel, i) => {
|
|
54
|
+
ids[rel] = (out ? out[i] : hashObjects(cwd, [rel])?.[0]) || `unhashable:${randomUUID()}`;
|
|
55
|
+
});
|
|
56
|
+
return ids;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Files added, modified or deleted in the working tree since `sha` (tracked
|
|
60
|
+
* diff plus untracked files), without the ones `ignore` names.
|
|
61
|
+
*/
|
|
62
|
+
export function changedFilesSince(cwd, sha, ignore) {
|
|
31
63
|
const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
|
|
32
64
|
if (!diff.ok)
|
|
33
65
|
return undefined;
|
|
34
|
-
|
|
66
|
+
const all = [...new Set([...paths(diff.stdout), ...untracked(cwd)])].sort();
|
|
67
|
+
return ignore ? all.filter((rel) => !ignore(rel)) : all;
|
|
35
68
|
}
|
|
36
69
|
/**
|
|
37
70
|
* Tracked files modified or deleted in the working tree since `sha` (staged
|
|
38
|
-
* or not); untracked files don't count.
|
|
71
|
+
* or not), without the ones `ignore` names; untracked files don't count.
|
|
72
|
+
* Undefined when git can't tell.
|
|
39
73
|
*/
|
|
40
|
-
export function trackedChangesSince(cwd, sha) {
|
|
74
|
+
export function trackedChangesSince(cwd, sha, ignore) {
|
|
41
75
|
const diff = git(cwd, "diff", "-z", "--name-only", "--no-renames", "--relative", sha, ...PATHSPEC);
|
|
42
|
-
|
|
76
|
+
if (!diff.ok)
|
|
77
|
+
return undefined;
|
|
78
|
+
const all = paths(diff.stdout).sort();
|
|
79
|
+
return ignore ? all.filter((rel) => !ignore(rel)) : all;
|
|
43
80
|
}
|
|
44
81
|
/** A file's content at `sha`, or undefined when it did not exist there. */
|
|
45
82
|
export function fileAt(cwd, sha, relativePath) {
|
|
46
83
|
const result = git(cwd, "show", `${sha}:./${relativePath.split(path.sep).join("/")}`);
|
|
47
84
|
return result.ok ? result.stdout : undefined;
|
|
48
85
|
}
|
|
49
|
-
function gitWithIndex(cwd, index,
|
|
86
|
+
function gitWithIndex(cwd, index, args, input) {
|
|
50
87
|
const result = spawnSync("git", args, {
|
|
51
88
|
cwd,
|
|
52
89
|
encoding: "utf8",
|
|
53
90
|
maxBuffer: 256 * 1024 * 1024,
|
|
54
|
-
|
|
91
|
+
input,
|
|
92
|
+
// Literal: a file named `[ab].txt` must not match `a.txt` when its path is passed back to git.
|
|
93
|
+
env: { ...process.env, GIT_INDEX_FILE: index, GIT_LITERAL_PATHSPECS: input === undefined ? undefined : "1" },
|
|
55
94
|
});
|
|
56
95
|
return { ok: result.status === 0, stdout: result.stdout ?? "" };
|
|
57
96
|
}
|
|
@@ -62,12 +101,13 @@ function repoPrefix(cwd) {
|
|
|
62
101
|
}
|
|
63
102
|
/**
|
|
64
103
|
* The git tree id of the working tree under `cwd` as it would be committed:
|
|
65
|
-
* tracked and untracked files, not ignored ones, without `.flagrm
|
|
66
|
-
* temporary copy of the index, so the user's
|
|
67
|
-
* the tree doesn't change it; any edit does.
|
|
68
|
-
* {@link treeAt} of HEAD
|
|
104
|
+
* tracked and untracked files, not ignored ones, without `.flagrm/` and the
|
|
105
|
+
* paths `ignore` names. Built in a temporary copy of the index, so the user's
|
|
106
|
+
* index is untouched. Committing the tree doesn't change it; any edit does.
|
|
107
|
+
* Before any edit it equals {@link treeAt} of HEAD, as long as the ignored
|
|
108
|
+
* paths are unchanged since.
|
|
69
109
|
*/
|
|
70
|
-
export function treeFingerprint(cwd) {
|
|
110
|
+
export function treeFingerprint(cwd, ignore) {
|
|
71
111
|
const prefix = repoPrefix(cwd);
|
|
72
112
|
if (prefix === undefined)
|
|
73
113
|
return undefined;
|
|
@@ -79,17 +119,41 @@ export function treeFingerprint(cwd) {
|
|
|
79
119
|
if (realIndex && fs.existsSync(realIndex))
|
|
80
120
|
fs.cpSync(realIndex, index, { preserveTimestamps: true });
|
|
81
121
|
// Not PATHSPEC: `git add` exits 1 when an exclude pathspec names an ignored path (`.flagrm/` usually is).
|
|
82
|
-
if (!gitWithIndex(cwd, index, "add", "-A", "--", ".").ok)
|
|
122
|
+
if (!gitWithIndex(cwd, index, ["add", "-A", "--", "."]).ok)
|
|
83
123
|
return undefined;
|
|
84
|
-
if (!gitWithIndex(cwd, index, "rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", ".flagrm").ok)
|
|
124
|
+
if (!gitWithIndex(cwd, index, ["rm", "-r", "-q", "--cached", "--ignore-unmatch", "--", ".flagrm"]).ok)
|
|
85
125
|
return undefined;
|
|
86
|
-
|
|
126
|
+
if (ignore && !dropIgnored(cwd, index, ignore))
|
|
127
|
+
return undefined;
|
|
128
|
+
const tree = gitWithIndex(cwd, index, ["write-tree", ...(prefix ? [`--prefix=${prefix}`] : [])]);
|
|
87
129
|
return tree.ok ? tree.stdout.trim() : undefined;
|
|
88
130
|
}
|
|
89
131
|
finally {
|
|
90
132
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
91
133
|
}
|
|
92
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Put back each path `ignore` names as it is at HEAD (or drop it, if HEAD
|
|
137
|
+
* doesn't have it), so the fingerprint doesn't see changes to it.
|
|
138
|
+
*/
|
|
139
|
+
function dropIgnored(cwd, index, ignore) {
|
|
140
|
+
const head = headSha(cwd);
|
|
141
|
+
// The paths that differ from HEAD, deleted ones included: `ls-files` no longer lists a deleted file.
|
|
142
|
+
// Before the first commit there is no HEAD to compare with, so every path in the index.
|
|
143
|
+
const listing = head
|
|
144
|
+
? gitWithIndex(cwd, index, ["diff-index", "--cached", "--name-only", "--no-renames", "--relative", "-z", "HEAD"])
|
|
145
|
+
: gitWithIndex(cwd, index, ["ls-files", "-z"]);
|
|
146
|
+
if (!listing.ok)
|
|
147
|
+
return false;
|
|
148
|
+
const listed = paths(listing.stdout).filter(ignore);
|
|
149
|
+
if (listed.length === 0)
|
|
150
|
+
return true;
|
|
151
|
+
const input = `${listed.join("\0")}\0`;
|
|
152
|
+
const fromFile = ["--pathspec-from-file=-", "--pathspec-file-nul"];
|
|
153
|
+
// `reset` also drops a path HEAD doesn't have; before the first commit there is no HEAD to reset to.
|
|
154
|
+
const args = head ? ["reset", "-q", "HEAD", ...fromFile] : ["rm", "-q", "--cached", ...fromFile];
|
|
155
|
+
return gitWithIndex(cwd, index, args, input).ok;
|
|
156
|
+
}
|
|
93
157
|
/** The tree id of `cwd` at commit `sha`. */
|
|
94
158
|
export function treeAt(cwd, sha) {
|
|
95
159
|
const prefix = repoPrefix(cwd);
|
package/dist/core/hook.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import {
|
|
9
|
+
import { changeFilterAt } from "./change-filter.js";
|
|
10
|
+
import { findConfigRoot } from "./config.js";
|
|
10
11
|
import { headSha, trackedChangesSince, treeFingerprint } from "./git.js";
|
|
11
12
|
import { readJson } from "./util.js";
|
|
12
13
|
/** The hook's stdin: only a JSON object counts, and only fields of the expected type are kept. */
|
|
@@ -35,9 +36,10 @@ export function hookRoot(cwd) {
|
|
|
35
36
|
/**
|
|
36
37
|
* Whether to block the stop: a removal is in progress and no passing verify
|
|
37
38
|
* matches the current tree. In progress means HEAD is still the baseline
|
|
38
|
-
* commit and a file git tracks changed, other than
|
|
39
|
-
* removal paused on a question to the user, or a
|
|
40
|
-
* doesn't count), or the last verify skipped checks.
|
|
39
|
+
* commit and a file git tracks changed, other than flagrm's setup files and
|
|
40
|
+
* paths matching `exclude` (a removal paused on a question to the user, or a
|
|
41
|
+
* stray test result file, doesn't count), or the last verify skipped checks.
|
|
42
|
+
* The fingerprint leaves out the same paths, as verify's does. Once HEAD moves (a
|
|
41
43
|
* commit, a branch switch, a merge), that removal is no longer guarded.
|
|
42
44
|
*/
|
|
43
45
|
export function stopDecision(root, input) {
|
|
@@ -57,11 +59,12 @@ export function stopDecision(root, input) {
|
|
|
57
59
|
.filter((flagDir) => readJson(path.join(flagDir, "baseline.json"))?.git?.sha === head);
|
|
58
60
|
if (active.length === 0)
|
|
59
61
|
return { block: false };
|
|
60
|
-
const
|
|
62
|
+
const ignore = changeFilterAt(root);
|
|
63
|
+
const edited = (trackedChangesSince(root, head, ignore) ?? []).length > 0;
|
|
61
64
|
const started = active.filter((flagDir) => edited || lastVerifySkipped(flagDir));
|
|
62
65
|
if (started.length === 0)
|
|
63
66
|
return { block: false };
|
|
64
|
-
const current = treeFingerprint(root);
|
|
67
|
+
const current = treeFingerprint(root, ignore);
|
|
65
68
|
if (!current)
|
|
66
69
|
return { block: false };
|
|
67
70
|
const reasons = started.map((flagDir) => unverified(flagDir, current)).filter((r) => r !== undefined);
|
package/dist/core/install.js
CHANGED
|
@@ -11,6 +11,7 @@ import path from "node:path";
|
|
|
11
11
|
import { DEFAULT_BACKEND_ATTRIBUTES, DEFAULT_BACKEND_METHODS, DEFAULT_FRONTEND_METHODS, findConfigRoot, findDefaultConfigFile, repositoryRoot, TOOL_IDS, } from "./config.js";
|
|
12
12
|
import { detectProjects } from "./detect.js";
|
|
13
13
|
import { packageRoot, packageVersion } from "./package.js";
|
|
14
|
+
import { runnerTestCommands, testResultsHint } from "./test-runner.js";
|
|
14
15
|
import { saveTools } from "./tools.js";
|
|
15
16
|
import { relativePath } from "./util.js";
|
|
16
17
|
export const SKILLS = ["flagrm-remove", "flagrm-verify"];
|
|
@@ -27,11 +28,11 @@ export const PROMPT_FILES = {
|
|
|
27
28
|
"flagrm-remove": [
|
|
28
29
|
"---",
|
|
29
30
|
"description: Remove a feature flag with flagrm's guardrails (keep the ON path, verify until it passes)",
|
|
30
|
-
"argument-hint: <flag>",
|
|
31
|
+
"argument-hint: <flag> [flag...]",
|
|
31
32
|
"agent: agent",
|
|
32
33
|
"---",
|
|
33
34
|
"",
|
|
34
|
-
"Follow `.github/skills/flagrm-remove/SKILL.md` to remove the feature
|
|
35
|
+
"Follow `.github/skills/flagrm-remove/SKILL.md` to remove the feature flags named after this command.",
|
|
35
36
|
"",
|
|
36
37
|
].join("\n"),
|
|
37
38
|
"flagrm-verify": [
|
|
@@ -61,8 +62,8 @@ export const AGENTS_BLOCK = [
|
|
|
61
62
|
AGENTS_START,
|
|
62
63
|
"## Feature flag removal (flagrm)",
|
|
63
64
|
"",
|
|
64
|
-
"To remove
|
|
65
|
-
"
|
|
65
|
+
"To remove one or more feature flags, follow `.agents/skills/flagrm-remove/SKILL.md`. To check a removal",
|
|
66
|
+
"later, follow `.agents/skills/flagrm-verify/SKILL.md`.",
|
|
66
67
|
AGENTS_END,
|
|
67
68
|
].join("\n");
|
|
68
69
|
const CONFIG_HEADER = [
|
|
@@ -89,14 +90,21 @@ const EXAMPLE_PROJECTS = [
|
|
|
89
90
|
{ name: "backend", adapter: "dotnet", path: "backend", marker: "backend/Backend.sln" },
|
|
90
91
|
];
|
|
91
92
|
/** The config lines for one project, with commented-out commands that fit it. */
|
|
92
|
-
function projectLines(p) {
|
|
93
|
+
function projectLines(p, cwd) {
|
|
93
94
|
const dir = p.path === "." ? "." : `./${p.path}`;
|
|
94
95
|
const inDir = (rel) => (p.path === "." ? `./${rel}` : `${dir}/${rel}`);
|
|
95
96
|
const lines = [` - name: ${p.name}`, ` adapter: ${p.adapter}`, ` path: ${dir}`];
|
|
96
97
|
if (p.adapter === "angular") {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
const results = path.join(cwd, ".flagrm", "test-results", p.name);
|
|
99
|
+
const runner = p.testRunner ?? { kind: "unknown" };
|
|
100
|
+
const tests = runnerTestCommands(runner, path.join(cwd, p.path), results);
|
|
101
|
+
lines.push(" # build: npx ng build", ` # test: ${tests?.test ?? "npx ng test --watch=false"}`, tests
|
|
102
|
+
? ` # testResults: ./${relativePath(cwd, tests.testResults)} # the default test command writes these`
|
|
103
|
+
: ` # testResults: ${inDir("test-results/junit.xml")} # JUnit XML, TRX or Jest JSON, so verify can compare tests`, ...(tests
|
|
104
|
+
? []
|
|
105
|
+
: [
|
|
106
|
+
` # to get test results: ${testResultsHint(runner, relativePath(path.join(cwd, p.path), results) || ".", `./${relativePath(cwd, results)}`, true, cwd)}`,
|
|
107
|
+
]), ` # methods: [${DEFAULT_FRONTEND_METHODS.join(", ")}] # flag evaluation methods (defaults shown)`);
|
|
100
108
|
}
|
|
101
109
|
else {
|
|
102
110
|
const solution = /\.slnx?$/.test(p.marker) ? ` ${path.posix.basename(p.marker)}` : "";
|
|
@@ -104,13 +112,20 @@ function projectLines(p) {
|
|
|
104
112
|
}
|
|
105
113
|
return lines;
|
|
106
114
|
}
|
|
107
|
-
export function configTemplate(projects, tools) {
|
|
115
|
+
export function configTemplate(projects, tools, cwd = process.cwd()) {
|
|
108
116
|
const listed = projects.length ? projects : EXAMPLE_PROJECTS;
|
|
109
117
|
const [comment1, comment2, projectsKey] = CONFIG_HEADER;
|
|
110
118
|
const toolLines = tools
|
|
111
119
|
? ["# The AI coding tools `flagrm init` and `flagrm update` set up.", `tools: [${tools.join(", ")}]`]
|
|
112
120
|
: [];
|
|
113
|
-
return [
|
|
121
|
+
return [
|
|
122
|
+
comment1,
|
|
123
|
+
comment2,
|
|
124
|
+
...toolLines,
|
|
125
|
+
projectsKey,
|
|
126
|
+
...listed.flatMap((p) => projectLines(p, cwd)),
|
|
127
|
+
...CONFIG_FOOTER,
|
|
128
|
+
].join("\n");
|
|
114
129
|
}
|
|
115
130
|
/** Write `flagrm.config.yaml` for the projects found in `cwd` (an example when none are) unless a config file exists. */
|
|
116
131
|
export function writeConfigTemplate(cwd, tools) {
|
|
@@ -119,7 +134,7 @@ export function writeConfigTemplate(cwd, tools) {
|
|
|
119
134
|
return { path: relativePath(cwd, existing), status: "skipped" };
|
|
120
135
|
const file = path.join(cwd, "flagrm.config.yaml");
|
|
121
136
|
const projects = detectProjects(cwd);
|
|
122
|
-
fs.writeFileSync(file, configTemplate(projects, tools), "utf8");
|
|
137
|
+
fs.writeFileSync(file, configTemplate(projects, tools, cwd), "utf8");
|
|
123
138
|
const note = projects.length
|
|
124
139
|
? `detected ${projects.map((p) => `${p.adapter} (${p.marker})`).join(", ")}`
|
|
125
140
|
: "no Angular or .NET project found: edit the example projects";
|
package/dist/core/inventory.js
CHANGED
|
@@ -138,7 +138,9 @@ function followFlow(projects, entries) {
|
|
|
138
138
|
continue;
|
|
139
139
|
const words = flagWords([{ name: entry.flag }, ...entry.definitions]);
|
|
140
140
|
const flagNames = [entry.flag, ...entry.definitions.map((d) => d.name.split(".").pop() ?? d.name)];
|
|
141
|
-
|
|
141
|
+
// A renamed import of one of the flag's definitions is as specific as the definition.
|
|
142
|
+
const record = name.kind === "alias" || (!name.ambiguous && recordable(name, flagNames, words));
|
|
143
|
+
entry.flow.push({ ...name, record });
|
|
142
144
|
}
|
|
143
145
|
for (const test of tests)
|
|
144
146
|
byFlag.get(test.flag)?.parameterizedTests.push(test);
|
package/dist/core/markdown.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* for the user or a PR. Every fact comes from the report; the agent adds
|
|
4
4
|
* notes and the commit message around it.
|
|
5
5
|
*/
|
|
6
|
+
import { reusedNote } from "./run-cache.js";
|
|
6
7
|
import { relativePath } from "./util.js";
|
|
7
8
|
import { shortTestName, shortTestNames } from "./verify/test-results.js";
|
|
8
9
|
const ICON = { pass: "✅", fail: "❌", warn: "⚠️", skipped: "➖" };
|
|
@@ -30,6 +31,9 @@ export function verifyMarkdown(report, root) {
|
|
|
30
31
|
"|---|---|---|",
|
|
31
32
|
...report.checks.map((c) => `| ${c.id} | ${ICON[c.status]} ${c.status} | ${c.summary.replace(/\|/g, "\\|")} |`),
|
|
32
33
|
];
|
|
34
|
+
const note = reusedNote(report.runs, report.createdAt);
|
|
35
|
+
if (note)
|
|
36
|
+
out.push("", `_${note}_`);
|
|
33
37
|
const changed = report.changedFiles ?? [];
|
|
34
38
|
if (changed.length)
|
|
35
39
|
out.push("", `**Files changed since baseline (${changed.length}):** ${codeList(changed)}`);
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which flags a name's code reads: where the name is declared or assigned
|
|
3
|
+
* (`name =`, `name:`, `name(...) {`, `Name =>`, `Name {`), and the flag
|
|
4
|
+
* literals and qualified names in what follows, up to the end of that
|
|
5
|
+
* statement or body. Lexical like the rest of discovery (comments and strings
|
|
6
|
+
* masked), so it works for TypeScript and C# alike and misses unusual shapes.
|
|
7
|
+
*
|
|
8
|
+
* `baseline --name` refuses a name that reads only other flags: once
|
|
9
|
+
* recorded, `leftovers` would make the agent delete another flag's code, and
|
|
10
|
+
* a recorded name can't be removed again.
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { UsageError } from "./baseline.js";
|
|
15
|
+
import { buildInventory } from "./inventory.js";
|
|
16
|
+
import { commentSyntax, computeLexicalMask, escapeRegExp, LEX_CODE, LEX_STRING } from "./scan.js";
|
|
17
|
+
import { SourceText } from "./source-text.js";
|
|
18
|
+
import { toLf } from "./text-utils.js";
|
|
19
|
+
import { relativePath } from "./util.js";
|
|
20
|
+
const MAX_LINES = 20;
|
|
21
|
+
const OPEN = "([{";
|
|
22
|
+
const CLOSE = ")]}";
|
|
23
|
+
/** Every declaration or assignment of `name` in `files` (absolute; every project file when undefined). */
|
|
24
|
+
export function nameReads(projects, name, files, flags) {
|
|
25
|
+
const only = files && new Set(files);
|
|
26
|
+
const word = new RegExp(`(?<![\\w$])${escapeRegExp(name)}(?![\\w$])`, "g");
|
|
27
|
+
const out = [];
|
|
28
|
+
const seen = new Set();
|
|
29
|
+
for (const { ctx } of projects) {
|
|
30
|
+
for (const file of ctx.files) {
|
|
31
|
+
if (seen.has(file) || (only && !only.has(file)) || commentSyntax(file) !== "slash")
|
|
32
|
+
continue;
|
|
33
|
+
seen.add(file);
|
|
34
|
+
let text;
|
|
35
|
+
try {
|
|
36
|
+
text = toLf(fs.readFileSync(file, "utf8"));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!text.includes(name))
|
|
42
|
+
continue;
|
|
43
|
+
const mask = computeLexicalMask(text, "slash");
|
|
44
|
+
const source = new SourceText(file, text);
|
|
45
|
+
for (const m of text.matchAll(word)) {
|
|
46
|
+
if (mask[m.index] !== LEX_CODE)
|
|
47
|
+
continue;
|
|
48
|
+
const after = m.index + name.length;
|
|
49
|
+
if (!isSite(text, mask, m.index, after))
|
|
50
|
+
continue;
|
|
51
|
+
const body = text.slice(after, statementEnd(text, mask, after));
|
|
52
|
+
out.push({ file, line: source.position(m.index).line, flags: flagsIn(body, flags) });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** A declaration or assignment of the name at `start..after`, not a read, a call, a ternary branch or a case label. */
|
|
59
|
+
function isSite(text, mask, start, after) {
|
|
60
|
+
const before = text.slice(Math.max(0, start - 40), start);
|
|
61
|
+
if (/\?\s*$/.test(before) || /\bcase\s+$/.test(before))
|
|
62
|
+
return false;
|
|
63
|
+
let i = after;
|
|
64
|
+
while (i < text.length && /[ \t]/.test(text[i]))
|
|
65
|
+
i++;
|
|
66
|
+
// `x?:` / `x!:` (optional, definite) and `x ??= y` declare or assign; `x != y`, `x ? a : b` and `x!.y` don't.
|
|
67
|
+
if (text[i] === "?" || text[i] === "!") {
|
|
68
|
+
const op = text.slice(i, i + 3);
|
|
69
|
+
return op[1] === ":" || op === "??=";
|
|
70
|
+
}
|
|
71
|
+
const rest = text.slice(i, i + 3);
|
|
72
|
+
if (rest.startsWith("=>"))
|
|
73
|
+
return true;
|
|
74
|
+
if (rest.startsWith("="))
|
|
75
|
+
return !rest.startsWith("==");
|
|
76
|
+
if (rest.startsWith(":"))
|
|
77
|
+
return true;
|
|
78
|
+
if (rest.startsWith("{"))
|
|
79
|
+
return true;
|
|
80
|
+
if (!rest.startsWith("("))
|
|
81
|
+
return false;
|
|
82
|
+
// A parameter list, then (after an optional return type, which may hold `|`, `.` or `<>`) a body or
|
|
83
|
+
// `=>`: a method. Anything else is a call: `;`/`,`/`}` or a line break first, or a `)` closing an
|
|
84
|
+
// enclosing bracket (`if (isX()) {`). Arrows and braces inside the call's own brackets don't count.
|
|
85
|
+
let depth = 0;
|
|
86
|
+
for (let j = i; j < Math.min(text.length, i + 400); j++) {
|
|
87
|
+
if (mask[j] !== LEX_CODE)
|
|
88
|
+
continue;
|
|
89
|
+
const c = text[j];
|
|
90
|
+
if (c === "(")
|
|
91
|
+
depth++;
|
|
92
|
+
else if (c === ")") {
|
|
93
|
+
if (--depth < 0)
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
else if (depth > 0)
|
|
97
|
+
continue;
|
|
98
|
+
else if (c === "{")
|
|
99
|
+
return true;
|
|
100
|
+
else if (c === "=" && text[j + 1] === ">")
|
|
101
|
+
return true;
|
|
102
|
+
else if (c === ";" || c === "," || c === "}")
|
|
103
|
+
return false;
|
|
104
|
+
else if (c === "\n") {
|
|
105
|
+
const head = text.slice(j + 1).trimStart();
|
|
106
|
+
return head.startsWith("{") || head.startsWith("=>");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
/** Where the statement or body starting at `from` ends: `;` or `,` at depth 0, a closing bracket below it, or a `{` body's `}`. */
|
|
112
|
+
function statementEnd(text, mask, from) {
|
|
113
|
+
let depth = 0;
|
|
114
|
+
let lines = 0;
|
|
115
|
+
for (let i = from; i < text.length; i++) {
|
|
116
|
+
if (text[i] === "\n" && ++lines >= MAX_LINES)
|
|
117
|
+
return i;
|
|
118
|
+
if (mask[i] !== LEX_CODE)
|
|
119
|
+
continue;
|
|
120
|
+
const c = text[i];
|
|
121
|
+
if (OPEN.includes(c))
|
|
122
|
+
depth++;
|
|
123
|
+
else if (CLOSE.includes(c)) {
|
|
124
|
+
depth--;
|
|
125
|
+
if (depth < 0)
|
|
126
|
+
return i;
|
|
127
|
+
if (depth === 0 && c === "}")
|
|
128
|
+
return i + 1;
|
|
129
|
+
}
|
|
130
|
+
else if (depth === 0 && (c === ";" || c === ","))
|
|
131
|
+
return i;
|
|
132
|
+
}
|
|
133
|
+
return text.length;
|
|
134
|
+
}
|
|
135
|
+
/** The flags whose literal (quoted) or qualified name (in code) occurs in `body`. */
|
|
136
|
+
function flagsIn(body, flags) {
|
|
137
|
+
const mask = computeLexicalMask(body, "slash");
|
|
138
|
+
return flags
|
|
139
|
+
.filter(({ flag, aliases }) => {
|
|
140
|
+
for (const m of body.matchAll(new RegExp(`(['"\`])${escapeRegExp(flag)}\\1`, "g"))) {
|
|
141
|
+
if (mask[m.index] === LEX_STRING)
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
return aliases.some((alias) => {
|
|
145
|
+
const pattern = alias.split(".").map(escapeRegExp).join("\\s*\\.\\s*");
|
|
146
|
+
for (const m of body.matchAll(new RegExp(`(?<![\\w$])${pattern}(?![\\w$])`, "g"))) {
|
|
147
|
+
if (mask[m.index] === LEX_CODE)
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
return false;
|
|
151
|
+
});
|
|
152
|
+
})
|
|
153
|
+
.map((f) => f.flag);
|
|
154
|
+
}
|
|
155
|
+
/** Throw a {@link UsageError} for the first agent name whose code reads only flags other than `flag`. */
|
|
156
|
+
export async function assertOwnNames(root, projects, flag, names) {
|
|
157
|
+
if (names.length === 0)
|
|
158
|
+
return;
|
|
159
|
+
const inventory = (await buildInventory(projects)).flags;
|
|
160
|
+
// A renamed import of the flag's registry (`AppFeatureFlags.X`) reads it as much as the definition does.
|
|
161
|
+
const refs = inventory.map((e) => ({
|
|
162
|
+
flag: e.flag,
|
|
163
|
+
aliases: [...e.definitions.map((d) => d.name), ...e.flow.filter((n) => n.kind === "alias").map((n) => n.name)],
|
|
164
|
+
}));
|
|
165
|
+
if (!refs.some((r) => r.flag === flag))
|
|
166
|
+
refs.push({ flag, aliases: [] });
|
|
167
|
+
for (const name of [...new Set(names.map((n) => n.name))]) {
|
|
168
|
+
const entries = names.filter((n) => n.name === name);
|
|
169
|
+
const files = entries.every((n) => n.file) ? entries.map((n) => path.resolve(root, n.file)) : undefined;
|
|
170
|
+
const sites = nameReads(projects, name, files, refs);
|
|
171
|
+
if (sites.some((s) => s.flags.includes(flag)))
|
|
172
|
+
continue;
|
|
173
|
+
const foreign = sites.find((s) => s.flags.length > 0);
|
|
174
|
+
if (!foreign)
|
|
175
|
+
continue;
|
|
176
|
+
throw new UsageError(`"${name}" reads ${foreign.flags.join(", ")}, not ${flag} (${relativePath(root, foreign.file)}:${foreign.line}); ` +
|
|
177
|
+
"not recorded. Record only names this flag's value goes through.");
|
|
178
|
+
}
|
|
179
|
+
}
|
package/dist/core/report.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
|
+
import { reusedNote } from "./run-cache.js";
|
|
2
3
|
import { plural, relativePath } from "./util.js";
|
|
3
4
|
/** A path relative to the working directory, with `/` separators on every platform. */
|
|
4
5
|
function rel(file) {
|
|
@@ -70,10 +71,19 @@ export function printBaseline(baseline, file) {
|
|
|
70
71
|
if (c.failure)
|
|
71
72
|
console.log(` ${pc.yellow("setup problem:")} ${c.failure.message}`);
|
|
72
73
|
}
|
|
74
|
+
const note = reusedNote(checks ?? [], baseline.createdAt);
|
|
75
|
+
if (note)
|
|
76
|
+
console.log(` ${pc.dim(note)}`);
|
|
73
77
|
if (baseline.names.length) {
|
|
74
78
|
const names = baseline.names.map((n) => n.kind === "literal" ? n.name : `${n.name} (${n.kind}${n.file ? ` in ${n.file}` : ""})`);
|
|
75
79
|
console.log(` names: ${pc.dim(names.join(", "))}`);
|
|
76
80
|
}
|
|
81
|
+
const accepted = [
|
|
82
|
+
baseline.acceptedConfig && "the config as of now",
|
|
83
|
+
baseline.acceptedFailures && "the baseline's failing tests",
|
|
84
|
+
].filter(Boolean);
|
|
85
|
+
if (accepted.length)
|
|
86
|
+
console.log(` accepted: ${pc.dim(accepted.join(", "))}`);
|
|
77
87
|
for (const n of baseline.suggestedNames ?? []) {
|
|
78
88
|
console.log(` ${pc.yellow("suggested")} ${n.name} ${pc.dim(`(${n.kind})`)} ${pc.cyan(`${rel(n.file)}:${n.line}`)} ` +
|
|
79
89
|
pc.dim(`if it only carries this flag: --name ${n.name} --kind wrapper` +
|
|
@@ -116,6 +126,9 @@ export function printVerifyReport(report, file) {
|
|
|
116
126
|
}
|
|
117
127
|
if (report.strict && warned)
|
|
118
128
|
console.log(pc.dim("\n --strict: warnings count as failures."));
|
|
129
|
+
const note = reusedNote(report.runs, report.createdAt);
|
|
130
|
+
if (note)
|
|
131
|
+
console.log(pc.dim(`\n ${note}`));
|
|
119
132
|
console.log(pc.dim(`\nResult written to ${rel(file)}.`));
|
|
120
133
|
}
|
|
121
134
|
const STEP_STYLE = {
|