@intentius/chant 0.79.0 → 0.80.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/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +38 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/terraform-state.d.ts +14 -0
- package/dist/audit/terraform-state.d.ts.map +1 -1
- package/dist/cli/command-group.d.ts +6 -0
- package/dist/cli/command-group.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +27 -2
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/conflict-check.d.ts +6 -0
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/dev.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts +17 -0
- package/dist/cli/handlers/promote.d.ts.map +1 -0
- package/dist/cli/handlers/run-generate.d.ts +39 -0
- package/dist/cli/handlers/run-generate.d.ts.map +1 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +9 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability-plugin-loader.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
- package/dist/components/presets/ecs-fargate.d.ts +10 -4
- package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
- package/dist/components/promote.d.ts +211 -0
- package/dist/components/promote.d.ts.map +1 -0
- package/dist/components/unbound-gate-approval.d.ts +24 -0
- package/dist/components/unbound-gate-approval.d.ts.map +1 -0
- package/dist/config.d.ts +82 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/convergence.d.ts +97 -0
- package/dist/discovery/convergence.d.ts.map +1 -0
- package/dist/discovery/files.d.ts +41 -2
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +13 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon-module.d.ts +65 -0
- package/dist/lexicon-module.d.ts.map +1 -0
- package/dist/lexicon.d.ts +24 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
- package/dist/lifecycle/legacy-digest.d.ts +26 -0
- package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +25 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
- package/dist/op/activity-contract-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/workspace/record-source.d.ts +22 -0
- package/dist/workspace/record-source.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +57 -0
- package/dist/workspace/records-cli.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +118 -0
- package/dist/workspace/records.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/audit/core.ts +13 -2
- package/src/audit/discover.ts +90 -12
- package/src/audit/terraform-state.test.ts +86 -1
- package/src/audit/terraform-state.ts +26 -0
- package/src/cli/command-group.ts +7 -0
- package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
- package/src/cli/commands/audit.ts +93 -19
- package/src/cli/commands/check-lexicon-examples.ts +2 -1
- package/src/cli/commands/doctor.ts +3 -0
- package/src/cli/commands/import-agents.ts +11 -4
- package/src/cli/commands/init.ts +57 -25
- package/src/cli/commands/lint.test.ts +58 -1
- package/src/cli/commands/lint.ts +48 -18
- package/src/cli/commands/onboard.ts +15 -0
- package/src/cli/commands/update.ts +5 -1
- package/src/cli/conflict-check.test.ts +18 -1
- package/src/cli/conflict-check.ts +13 -0
- package/src/cli/discovery-skip.test.ts +168 -0
- package/src/cli/handlers/build.test.ts +11 -0
- package/src/cli/handlers/build.ts +1 -1
- package/src/cli/handlers/dev.ts +3 -0
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +4 -2
- package/src/cli/handlers/lifecycle.ts +2 -1
- package/src/cli/handlers/misc.ts +16 -0
- package/src/cli/handlers/operator.ts +2 -1
- package/src/cli/handlers/promote.test.ts +261 -0
- package/src/cli/handlers/promote.ts +325 -0
- package/src/cli/handlers/run-generate.test.ts +160 -0
- package/src/cli/handlers/run-generate.ts +133 -0
- package/src/cli/handlers/run.ts +13 -1
- package/src/cli/main.test.ts +33 -1
- package/src/cli/main.ts +69 -16
- package/src/cli/mcp/op-tools.ts +2 -1
- package/src/cli/path-lexicon-messages.test.ts +152 -0
- package/src/cli/plugins.ts +44 -6
- package/src/cli/registry.ts +14 -1
- package/src/components/__fixtures__/alb-ecs-service.json +0 -8
- package/src/components/capability-plugin-loader.ts +3 -1
- package/src/components/cli-support.ts +3 -2
- package/src/components/discover.ts +26 -2
- package/src/components/driver.test.ts +12 -11
- package/src/components/driver.ts +2 -0
- package/src/components/fan-out-support.ts +2 -1
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +7 -7
- package/src/components/presets/ecs-fargate.ts +10 -5
- package/src/components/promote.test.ts +371 -0
- package/src/components/promote.ts +537 -0
- package/src/components/unbound-gate-approval.test.ts +82 -0
- package/src/components/unbound-gate-approval.ts +51 -0
- package/src/config.test.ts +40 -0
- package/src/config.ts +128 -11
- package/src/discovery/convergence.test.ts +231 -0
- package/src/discovery/convergence.ts +399 -0
- package/src/discovery/files.test.ts +127 -1
- package/src/discovery/files.ts +97 -9
- package/src/discovery/fold-import.ts +83 -9
- package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
- package/src/graph-ir.ts +14 -0
- package/src/lexicon-module.test.ts +203 -0
- package/src/lexicon-module.ts +117 -0
- package/src/lexicon.ts +26 -0
- package/src/lifecycle/build-ledger-store.ts +11 -1
- package/src/lifecycle/legacy-digest.test.ts +114 -0
- package/src/lifecycle/legacy-digest.ts +62 -0
- package/src/lifecycle/release-ledger.ts +34 -0
- package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
- package/src/op/activity-contract-registry.ts +18 -4
- package/src/op/activity-registry.ts +26 -0
- package/src/op/discover-convergence.test.ts +70 -0
- package/src/op/discover.ts +4 -0
- package/src/op/gate-approval.ts +3 -1
- package/src/op/generate-pipeline.ts +2 -1
- package/src/op/runtimes/local.ts +2 -1
- package/src/workspace/record-source.ts +117 -0
- package/src/workspace/records-cli.ts +126 -0
- package/src/workspace/records-contract.test.ts +88 -0
- package/src/workspace/records.schema.json +120 -0
- package/src/workspace/records.test.ts +243 -0
- package/src/workspace/records.ts +383 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The warning release for chant#2527, which converges the discovery walkers.
|
|
3
|
+
*
|
|
4
|
+
* Five walkers decide which files chant reads: source discovery
|
|
5
|
+
* (`./files.ts`'s `findInfraFiles`, behind build, graph, list and explain),
|
|
6
|
+
* lint (`../cli/commands/lint.ts`), component discovery
|
|
7
|
+
* (`../components/discover.ts`), Op discovery (`../op/discover.ts`) and the
|
|
8
|
+
* audit walk (`../audit/discover.ts`). They disagree about dot-directories,
|
|
9
|
+
* git-ignored files, `dist` and child projects. The next release makes them
|
|
10
|
+
* agree:
|
|
11
|
+
*
|
|
12
|
+
* - dot-directories are skipped, except `.github` and `.forgejo` for audit;
|
|
13
|
+
* - git-ignored files are skipped, nested `.gitignore` files included, with
|
|
14
|
+
* ignore rules evaluated only for paths below the scan root;
|
|
15
|
+
* - `dist` is skipped;
|
|
16
|
+
* - when the scan root is inside a project, a child directory holding a
|
|
17
|
+
* project config is a boundary (a lint-only `chant.config.json` fragment is
|
|
18
|
+
* not one); when it is outside a project, every child project is read; Op
|
|
19
|
+
* discovery always stops at child projects below its root;
|
|
20
|
+
* - the source-root quirk goes: today source and component discovery read
|
|
21
|
+
* the first child project they meet as the project's own source and skip
|
|
22
|
+
* every later one.
|
|
23
|
+
*
|
|
24
|
+
* This release changes none of that. It runs after each walk, classifies the
|
|
25
|
+
* files the walk returned (and, where the next release reads more, the
|
|
26
|
+
* subtrees the walk skipped) under the converged rules, and prints one
|
|
27
|
+
* warning per affected group on stderr, naming the `include` or `exclude`
|
|
28
|
+
* glob (#2519) that keeps today's behaviour. The warning goes away only when
|
|
29
|
+
* that glob is in the project config.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { execFileSync } from "node:child_process";
|
|
33
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
34
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
35
|
+
// @ts-ignore — picomatch has no types declaration
|
|
36
|
+
import picomatch from "picomatch";
|
|
37
|
+
import { findProjectConfig } from "../project-root";
|
|
38
|
+
import { isLintOnlyFragment, loadChantConfig } from "../config";
|
|
39
|
+
|
|
40
|
+
/** The walker a warning is about, which is also how the warning names it. */
|
|
41
|
+
export type DiscoveryWalker = "source" | "lint" | "components" | "ops" | "audit";
|
|
42
|
+
|
|
43
|
+
const WALKER_LABEL: Record<DiscoveryWalker, string> = {
|
|
44
|
+
source: "Source discovery (build, graph, list, explain)",
|
|
45
|
+
lint: "chant lint",
|
|
46
|
+
components: "Component discovery",
|
|
47
|
+
ops: "Op discovery",
|
|
48
|
+
audit: "chant audit",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Why a file's status changes next release. */
|
|
52
|
+
type Cause = "dot-dir" | "dist" | "child-project" | "source-root" | "git-ignored" | "every-child" | "ignored-above-root";
|
|
53
|
+
|
|
54
|
+
interface Change {
|
|
55
|
+
file: string;
|
|
56
|
+
/** `skip`: read today, skipped next release. `read`: the reverse. */
|
|
57
|
+
kind: "skip" | "read";
|
|
58
|
+
cause: Cause;
|
|
59
|
+
/** The directory (or, for a lone ignored file, the file) the cause attaches to. */
|
|
60
|
+
anchor: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Dot-directories the audit walk keeps, since they hold the CI files it audits. */
|
|
64
|
+
const AUDIT_DOT_DIRS = new Set([".github", ".forgejo"]);
|
|
65
|
+
|
|
66
|
+
/** Generated type stubs `chant init` and `chant update` write: never worth a warning. */
|
|
67
|
+
const GENERATED_TYPES = `${sep}.chant${sep}types${sep}`;
|
|
68
|
+
|
|
69
|
+
export interface DiscoveryWalkInput {
|
|
70
|
+
walker: DiscoveryWalker;
|
|
71
|
+
/** The directory the walk started from. */
|
|
72
|
+
root: string;
|
|
73
|
+
/** The files the walk returned, absolute. */
|
|
74
|
+
files: readonly string[];
|
|
75
|
+
/**
|
|
76
|
+
* Source and component discovery: the first child project the walk met,
|
|
77
|
+
* which it read as the project's own source.
|
|
78
|
+
*/
|
|
79
|
+
sourceRoot?: string | null;
|
|
80
|
+
/**
|
|
81
|
+
* Source and component discovery: the child projects the walk skipped
|
|
82
|
+
* because it had already met one. Read next release when the scan root is
|
|
83
|
+
* outside a project.
|
|
84
|
+
*/
|
|
85
|
+
skippedChildren?: readonly string[];
|
|
86
|
+
/** The walker's file filter, applied to what a skipped child holds. */
|
|
87
|
+
fileOk?: (name: string, fullPath: string) => boolean | Promise<boolean>;
|
|
88
|
+
/**
|
|
89
|
+
* Lint only: the files the walk found before its git-ignore filter dropped
|
|
90
|
+
* some, and the paths that filter reported ignored. The filter's call
|
|
91
|
+
* carries the scan root as an extra probe, so no second `git` call is made.
|
|
92
|
+
*/
|
|
93
|
+
lintIgnored?: { raw: readonly string[]; ignored: ReadonlySet<string> };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Paths `git check-ignore` reports ignored, in one batched call run from
|
|
98
|
+
* `cwd`. A path is reported exactly as given. A tree outside git, a missing
|
|
99
|
+
* `git`, or nothing ignored (exit 1) yields an empty set.
|
|
100
|
+
*/
|
|
101
|
+
export function gitIgnoredPaths(paths: readonly string[], cwd: string): Set<string> {
|
|
102
|
+
if (paths.length === 0) return new Set();
|
|
103
|
+
try {
|
|
104
|
+
const out = execFileSync("git", ["check-ignore", "--stdin"], {
|
|
105
|
+
cwd,
|
|
106
|
+
input: paths.join("\n"),
|
|
107
|
+
encoding: "utf-8",
|
|
108
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
109
|
+
});
|
|
110
|
+
return new Set(out.split(/\r?\n/).filter(Boolean));
|
|
111
|
+
} catch {
|
|
112
|
+
return new Set();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The probe for a directory in a `git check-ignore` batch: a trailing slash makes git match it as a directory. */
|
|
117
|
+
export function dirProbe(dir: string): string {
|
|
118
|
+
return dir.endsWith(sep) ? dir : dir + sep;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A child directory that ends a scan inside a project next release: it holds a project config, not a lint-only fragment. */
|
|
122
|
+
function isProjectBoundary(dir: string): boolean {
|
|
123
|
+
if (existsSync(join(dir, "chant.config.ts"))) return true;
|
|
124
|
+
const json = join(dir, "chant.config.json");
|
|
125
|
+
return existsSync(json) && !isLintOnlyFragment(json);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Ancestor directories of `file` strictly below `root`, outermost first. */
|
|
129
|
+
function dirsBetween(root: string, file: string): string[] {
|
|
130
|
+
const rel = relative(root, dirname(file));
|
|
131
|
+
if (rel === "" || rel.startsWith("..")) return [];
|
|
132
|
+
const parts = rel.split(sep);
|
|
133
|
+
return parts.map((_, i) => join(root, ...parts.slice(0, i + 1)));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Walk a subtree the old walker skipped, under the converged directory rules. */
|
|
137
|
+
async function walkConverged(
|
|
138
|
+
dir: string,
|
|
139
|
+
walker: DiscoveryWalker,
|
|
140
|
+
fileOk: (name: string, fullPath: string) => boolean | Promise<boolean>,
|
|
141
|
+
out: string[],
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
let entries;
|
|
144
|
+
try {
|
|
145
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
146
|
+
} catch {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
const full = join(dir, entry.name);
|
|
151
|
+
if (entry.isDirectory()) {
|
|
152
|
+
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
|
153
|
+
if (entry.name.startsWith(".") && !(walker === "audit" && AUDIT_DOT_DIRS.has(entry.name))) continue;
|
|
154
|
+
await walkConverged(full, walker, fileOk, out);
|
|
155
|
+
} else if (entry.isFile() && (await fileOk(entry.name, full))) {
|
|
156
|
+
out.push(full);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Classify a walk's files under the converged rules and return what changes.
|
|
163
|
+
* Exported for tests; {@link warnDiscoveryChanges} is the entry point.
|
|
164
|
+
*/
|
|
165
|
+
export async function discoveryChanges(input: DiscoveryWalkInput): Promise<Change[]> {
|
|
166
|
+
const root = resolve(input.root);
|
|
167
|
+
const walker = input.walker;
|
|
168
|
+
const inProject = findProjectConfig(root).configPath !== undefined;
|
|
169
|
+
const boundaries = walker === "ops" || inProject;
|
|
170
|
+
const sourceRoot = input.sourceRoot ? resolve(input.sourceRoot) : null;
|
|
171
|
+
|
|
172
|
+
const boundaryMemo = new Map<string, boolean>();
|
|
173
|
+
const boundary = (dir: string): boolean => {
|
|
174
|
+
let hit = boundaryMemo.get(dir);
|
|
175
|
+
if (hit === undefined) {
|
|
176
|
+
hit = isProjectBoundary(dir);
|
|
177
|
+
boundaryMemo.set(dir, hit);
|
|
178
|
+
}
|
|
179
|
+
return hit;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** The directory rule that skips `file` next release, outermost first. */
|
|
183
|
+
const dirCause = (file: string): { cause: Cause; anchor: string } | undefined => {
|
|
184
|
+
for (const dir of dirsBetween(root, file)) {
|
|
185
|
+
const name = dir.slice(dir.lastIndexOf(sep) + 1);
|
|
186
|
+
if (name.startsWith(".") && !(walker === "audit" && AUDIT_DOT_DIRS.has(name))) return { cause: "dot-dir", anchor: dir };
|
|
187
|
+
if (name === "dist") return { cause: "dist", anchor: dir };
|
|
188
|
+
if (boundaries && boundary(dir)) return { cause: dir === sourceRoot ? "source-root" : "child-project", anchor: dir };
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const changes: Change[] = [];
|
|
194
|
+
const pendingIgnore: string[] = [];
|
|
195
|
+
const lint = input.lintIgnored;
|
|
196
|
+
const lintRootIgnored = lint ? lint.ignored.has(dirProbe(root)) || lint.ignored.has(dirProbe(input.root)) : false;
|
|
197
|
+
|
|
198
|
+
const files = lint ? lint.raw : input.files;
|
|
199
|
+
for (const given of files) {
|
|
200
|
+
// Walkers join onto the path they were given, which may be relative;
|
|
201
|
+
// lint's ignored set holds the paths exactly as it passed them to git.
|
|
202
|
+
const file = resolve(given);
|
|
203
|
+
if (file.includes(GENERATED_TYPES)) continue;
|
|
204
|
+
const skip = dirCause(file);
|
|
205
|
+
if (lint) {
|
|
206
|
+
const ignoredToday = lint.ignored.has(given);
|
|
207
|
+
if (ignoredToday && lintRootIgnored && !skip) {
|
|
208
|
+
// Ignored only because the scan root itself is: today's filter drops
|
|
209
|
+
// it, the converged rule (ignore rules below the root only) reads it.
|
|
210
|
+
changes.push({ file, kind: "read", cause: "ignored-above-root", anchor: root });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (ignoredToday) continue; // skipped today and next release alike
|
|
214
|
+
}
|
|
215
|
+
if (skip) {
|
|
216
|
+
changes.push({ file, kind: "skip", ...skip });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!lint) pendingIgnore.push(file);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Outside a project the next release reads every child project; today the
|
|
223
|
+
// walk read only the first one it met.
|
|
224
|
+
const added: string[] = [];
|
|
225
|
+
const addedAnchor = new Map<string, string>();
|
|
226
|
+
if (!inProject && input.skippedChildren?.length && input.fileOk) {
|
|
227
|
+
for (const child of input.skippedChildren) {
|
|
228
|
+
const found: string[] = [];
|
|
229
|
+
await walkConverged(child, walker, input.fileOk, found);
|
|
230
|
+
for (const f of found) {
|
|
231
|
+
if (f.includes(GENERATED_TYPES)) continue;
|
|
232
|
+
added.push(f);
|
|
233
|
+
addedAnchor.set(f, child);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// One batched `git check-ignore` for everything the converged walk would
|
|
239
|
+
// otherwise read, plus the scan root and the directories between (to name
|
|
240
|
+
// the outermost ignored directory, and to apply ignore rules below the scan
|
|
241
|
+
// root only).
|
|
242
|
+
if (pendingIgnore.length > 0 || added.length > 0) {
|
|
243
|
+
const dirs = new Set<string>();
|
|
244
|
+
for (const f of [...pendingIgnore, ...added]) for (const d of dirsBetween(root, f)) dirs.add(dirProbe(d));
|
|
245
|
+
const probes = [dirProbe(root), ...dirs, ...pendingIgnore, ...added];
|
|
246
|
+
const ignored = gitIgnoredPaths(probes, root);
|
|
247
|
+
if (ignored.size > 0 && !ignored.has(dirProbe(root))) {
|
|
248
|
+
const ignoredAnchor = (file: string): string =>
|
|
249
|
+
dirsBetween(root, file).find((d) => ignored.has(dirProbe(d))) ?? file;
|
|
250
|
+
for (const file of pendingIgnore) {
|
|
251
|
+
if (ignored.has(file)) changes.push({ file, kind: "skip", cause: "git-ignored", anchor: ignoredAnchor(file) });
|
|
252
|
+
}
|
|
253
|
+
for (let i = added.length - 1; i >= 0; i--) if (ignored.has(added[i])) added.splice(i, 1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
for (const file of added) changes.push({ file, kind: "read", cause: "every-child", anchor: addedAnchor.get(file)! });
|
|
257
|
+
|
|
258
|
+
return changes;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The config a fix goes in: the project config above `root`, lint-only fragments skipped. */
|
|
262
|
+
function globsHome(root: string): { dir: string; configPath?: string } {
|
|
263
|
+
let { dir, configPath } = findProjectConfig(root);
|
|
264
|
+
while (configPath && isLintOnlyFragment(configPath)) {
|
|
265
|
+
const parent = dirname(dir);
|
|
266
|
+
if (parent === dir) break;
|
|
267
|
+
({ dir, configPath } = findProjectConfig(parent));
|
|
268
|
+
}
|
|
269
|
+
return configPath ? { dir, configPath } : { dir: root };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function stringList(value: unknown): string[] {
|
|
273
|
+
return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string" && v.length > 0) : [];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A matcher with #2519's semantics: a pattern, relative to `home`, matches a
|
|
278
|
+
* path when it matches the path or any directory above it. Dot files included.
|
|
279
|
+
*/
|
|
280
|
+
function globMatcher(patterns: string[], home: string): (path: string) => boolean {
|
|
281
|
+
if (patterns.length === 0) return () => false;
|
|
282
|
+
const match: (path: string) => boolean = picomatch(patterns, { dot: true });
|
|
283
|
+
return (path) => {
|
|
284
|
+
const rel = relative(home, path);
|
|
285
|
+
if (rel === "" || rel.startsWith("..")) return false;
|
|
286
|
+
const parts = rel.split(sep);
|
|
287
|
+
return parts.some((_, i) => match(parts.slice(0, i + 1).join("/")));
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Per cause: what the anchor is today, and what the next release does with it.
|
|
293
|
+
* `a` is the anchor as printed (a directory ends in `/`), `r` the scan root.
|
|
294
|
+
*/
|
|
295
|
+
const REASON: Record<Cause, (a: string, r: string) => [string, string]> = {
|
|
296
|
+
"dot-dir": (a) => [`${a} is a dot-directory`, "Every walker skips dot-directories from the next release"],
|
|
297
|
+
dist: (a) => [`${a} is a dist directory`, "Every walker skips dist from the next release"],
|
|
298
|
+
"child-project": (a) => [
|
|
299
|
+
`${a} is a child project with its own chant.config`,
|
|
300
|
+
"Discovery stops at child projects from the next release",
|
|
301
|
+
],
|
|
302
|
+
"source-root": (a) => [
|
|
303
|
+
`${a} is a child project with its own chant.config, read today as this project's own source because the walk met it first`,
|
|
304
|
+
"From the next release discovery stops there, as at any child project",
|
|
305
|
+
],
|
|
306
|
+
"git-ignored": (a) => [`${a} is git-ignored`, "Every walker skips git-ignored files from the next release"],
|
|
307
|
+
"every-child": (a, r) => [
|
|
308
|
+
`${a} is a child project, skipped today because the walk met another child project first`,
|
|
309
|
+
`From the next release every child project is read, since ${r} is not inside a chant project`,
|
|
310
|
+
],
|
|
311
|
+
"ignored-above-root": (_a, r) => [
|
|
312
|
+
`${r} is itself git-ignored, so today nothing under it is linted`,
|
|
313
|
+
"From the next release ignore rules apply below the scan root only, so the files under it are linted",
|
|
314
|
+
],
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const MAX_LISTED = 3;
|
|
318
|
+
const printed = new Set<string>();
|
|
319
|
+
|
|
320
|
+
/** Forget which warnings were printed. Tests only. */
|
|
321
|
+
export function resetDiscoveryWarnings(): void {
|
|
322
|
+
printed.clear();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Print, on stderr, what the next release's converged walk will read
|
|
327
|
+
* differently from the walk just made (chant#2527). Each group names its
|
|
328
|
+
* files, what changes, and the `include` or `exclude` glob (#2519) that
|
|
329
|
+
* keeps today's behaviour; a group whose glob is already in the project
|
|
330
|
+
* config is not printed. Each warning prints once per process, however many
|
|
331
|
+
* times a command walks the same tree. Returns the lines printed.
|
|
332
|
+
*/
|
|
333
|
+
export async function warnDiscoveryChanges(input: DiscoveryWalkInput): Promise<string[]> {
|
|
334
|
+
let changes: Change[];
|
|
335
|
+
try {
|
|
336
|
+
changes = await discoveryChanges(input);
|
|
337
|
+
} catch {
|
|
338
|
+
return []; // the warning must never break discovery
|
|
339
|
+
}
|
|
340
|
+
if (changes.length === 0) return [];
|
|
341
|
+
|
|
342
|
+
const root = resolve(input.root);
|
|
343
|
+
const home = globsHome(root);
|
|
344
|
+
let include: string[] = [];
|
|
345
|
+
let exclude: string[] = [];
|
|
346
|
+
if (home.configPath) {
|
|
347
|
+
try {
|
|
348
|
+
const { config } = await loadChantConfig(home.dir);
|
|
349
|
+
const raw = config as unknown as Record<string, unknown>;
|
|
350
|
+
include = stringList(raw.include);
|
|
351
|
+
exclude = stringList(raw.exclude);
|
|
352
|
+
} catch {
|
|
353
|
+
// the command's own config load reports a broken config
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const included = globMatcher(include, home.dir);
|
|
357
|
+
const excluded = globMatcher(exclude, home.dir);
|
|
358
|
+
|
|
359
|
+
const groups = new Map<string, Change[]>();
|
|
360
|
+
for (const c of changes) {
|
|
361
|
+
if (c.kind === "skip" ? included(c.file) : excluded(c.file)) continue;
|
|
362
|
+
const key = `${c.kind}\0${c.cause}\0${c.anchor}`;
|
|
363
|
+
const list = groups.get(key);
|
|
364
|
+
if (list) list.push(c);
|
|
365
|
+
else groups.set(key, [c]);
|
|
366
|
+
}
|
|
367
|
+
if (groups.size === 0) return [];
|
|
368
|
+
|
|
369
|
+
// The header names the scan root from the working directory; the files
|
|
370
|
+
// and directories in each line are relative to the scan root.
|
|
371
|
+
const cwd = process.cwd();
|
|
372
|
+
const showDir = (p: string): string => relative(cwd, p) || "the current directory";
|
|
373
|
+
const underRoot = (p: string): string => relative(root, p).split(sep).join("/");
|
|
374
|
+
const where = relative(cwd, home.configPath ?? join(home.dir, "chant.config.ts")) + (home.configPath ? "" : " (a new file)");
|
|
375
|
+
|
|
376
|
+
const lines: string[] = [];
|
|
377
|
+
for (const group of [...groups.values()].sort((a, b) => (a[0].anchor < b[0].anchor ? -1 : a[0].anchor > b[0].anchor ? 1 : 0))) {
|
|
378
|
+
const { kind, cause, anchor } = group[0];
|
|
379
|
+
const files = group.map((c) => c.file).sort();
|
|
380
|
+
const anchorIsFile = files.length === 1 && files[0] === anchor;
|
|
381
|
+
const shownAnchor = anchorIsFile ? underRoot(anchor) : `${underRoot(anchor)}/`;
|
|
382
|
+
const named = files.length <= MAX_LISTED ? files.map(underRoot).join(", ") : `${files.length} files under ${shownAnchor}`;
|
|
383
|
+
const glob = relative(home.dir, anchor).split(sep).join("/") || "**";
|
|
384
|
+
const [today, next] = REASON[cause](shownAnchor, showDir(root));
|
|
385
|
+
const them = files.length === 1 ? "it" : "them";
|
|
386
|
+
const fix =
|
|
387
|
+
kind === "skip"
|
|
388
|
+
? `To keep reading ${them} after the change, add "${glob}" to include in ${where}, which the next release honours.`
|
|
389
|
+
: `To keep skipping ${them}, add "${glob}" to exclude in ${where}.`;
|
|
390
|
+
lines.push(anchorIsFile && cause === "git-ignored" ? ` ${today}. ${next}. ${fix}` : ` ${named}: ${today}. ${next}. ${fix}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const header = `warning: ${WALKER_LABEL[input.walker]} under ${showDir(root)} changes in the next release (chant#2527):`;
|
|
394
|
+
const text = [header, ...lines].join("\n");
|
|
395
|
+
if (printed.has(text)) return lines;
|
|
396
|
+
printed.add(text);
|
|
397
|
+
console.error(text);
|
|
398
|
+
return lines;
|
|
399
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { findInfraFiles } from "./files";
|
|
2
|
+
import { findInfraFiles, compileDiscoveryFilter, hasSkipMarker, SKIP_MARKER } from "./files";
|
|
3
3
|
import { withTestDir } from "@intentius/chant-test-utils";
|
|
4
4
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
5
5
|
import { join } from "node:path";
|
|
@@ -169,3 +169,129 @@ describe("findInfraFiles", () => {
|
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
171
|
});
|
|
172
|
+
|
|
173
|
+
describe("findInfraFiles — project exclude/include globs (#2519)", () => {
|
|
174
|
+
/** Lay out a project: `files` maps a relative path to its contents. */
|
|
175
|
+
async function project(testDir: string, config: Record<string, unknown> | undefined, files: Record<string, string>) {
|
|
176
|
+
if (config) await writeFile(join(testDir, "chant.config.json"), JSON.stringify(config));
|
|
177
|
+
for (const [rel, body] of Object.entries(files)) {
|
|
178
|
+
await mkdir(join(testDir, rel, ".."), { recursive: true });
|
|
179
|
+
await writeFile(join(testDir, rel), body);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const rel = (testDir: string, files: string[]) => files.map((f) => f.slice(testDir.length + 1)).sort();
|
|
183
|
+
|
|
184
|
+
const LAYOUT = {
|
|
185
|
+
"src/app.ts": "export const app = {};",
|
|
186
|
+
"src/lib/util.ts": "export const util = {};",
|
|
187
|
+
"ops/run.ts": "process.exit(2);",
|
|
188
|
+
"ops/keep.ts": "export const keep = {};",
|
|
189
|
+
".stage/copy.ts": "export const copy = {};",
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
test("with neither key configured, discovery returns exactly what it did before", async () => {
|
|
193
|
+
await withTestDir(async (testDir) => {
|
|
194
|
+
await project(testDir, { lexicons: ["aws"] }, LAYOUT);
|
|
195
|
+
expect(rel(testDir, await findInfraFiles(testDir))).toEqual([
|
|
196
|
+
".stage/copy.ts", "ops/keep.ts", "ops/run.ts", "src/app.ts", "src/lib/util.ts",
|
|
197
|
+
]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test.each([["ops"], ["ops/**"], ["ops/*.ts"]])("exclude %s skips everything under ops/", async (pattern) => {
|
|
202
|
+
await withTestDir(async (testDir) => {
|
|
203
|
+
await project(testDir, { exclude: [pattern] }, LAYOUT);
|
|
204
|
+
expect(rel(testDir, await findInfraFiles(testDir))).toEqual([".stage/copy.ts", "src/app.ts", "src/lib/util.ts"]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("patterns match dot directories", async () => {
|
|
209
|
+
await withTestDir(async (testDir) => {
|
|
210
|
+
await project(testDir, { exclude: [".stage"] }, LAYOUT);
|
|
211
|
+
expect(rel(testDir, await findInfraFiles(testDir))).not.toContain(".stage/copy.ts");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("include wins over exclude", async () => {
|
|
216
|
+
await withTestDir(async (testDir) => {
|
|
217
|
+
await project(testDir, { exclude: ["ops"], include: ["ops/keep.ts"] }, LAYOUT);
|
|
218
|
+
expect(rel(testDir, await findInfraFiles(testDir))).toEqual([
|
|
219
|
+
".stage/copy.ts", "ops/keep.ts", "src/app.ts", "src/lib/util.ts",
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("exclude everything and include a directory narrows discovery to it", async () => {
|
|
225
|
+
await withTestDir(async (testDir) => {
|
|
226
|
+
await project(testDir, { exclude: ["**"], include: ["src"] }, LAYOUT);
|
|
227
|
+
expect(rel(testDir, await findInfraFiles(testDir))).toEqual(["src/app.ts", "src/lib/util.ts"]);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("include alone changes nothing", async () => {
|
|
232
|
+
await withTestDir(async (testDir) => {
|
|
233
|
+
await project(testDir, { include: ["src"] }, LAYOUT);
|
|
234
|
+
expect(await findInfraFiles(testDir)).toHaveLength(5);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("patterns stay relative to the project root when discovery starts in a subdirectory", async () => {
|
|
239
|
+
await withTestDir(async (testDir) => {
|
|
240
|
+
await project(testDir, { exclude: ["src/lib"] }, LAYOUT);
|
|
241
|
+
expect(rel(testDir, await findInfraFiles(join(testDir, "src")))).toEqual(["src/app.ts"]);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("include does not re-admit test files", async () => {
|
|
246
|
+
await withTestDir(async (testDir) => {
|
|
247
|
+
await project(testDir, { exclude: ["ops"], include: ["**"] }, { ...LAYOUT, "src/app.test.ts": "test();" });
|
|
248
|
+
expect(rel(testDir, await findInfraFiles(testDir))).not.toContain("src/app.test.ts");
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("explicit globs override the config; null applies none", async () => {
|
|
253
|
+
await withTestDir(async (testDir) => {
|
|
254
|
+
await project(testDir, { exclude: ["ops"] }, LAYOUT);
|
|
255
|
+
expect(await findInfraFiles(testDir, { globs: null })).toHaveLength(5);
|
|
256
|
+
const only = await findInfraFiles(testDir, { globs: { root: testDir, exclude: ["src"], include: [] } });
|
|
257
|
+
expect(rel(testDir, only)).toEqual([".stage/copy.ts", "ops/keep.ts", "ops/run.ts"]);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("compileDiscoveryFilter (#2519)", () => {
|
|
263
|
+
test("is undefined with no exclude patterns", () => {
|
|
264
|
+
expect(compileDiscoveryFilter(undefined)).toBeUndefined();
|
|
265
|
+
expect(compileDiscoveryFilter({ root: "/p", exclude: [], include: ["**"] })).toBeUndefined();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("never skips a file outside the root", () => {
|
|
269
|
+
const skip = compileDiscoveryFilter({ root: "/p/app", exclude: ["**"], include: [] })!;
|
|
270
|
+
expect(skip("/p/app/x.ts")).toBe(true);
|
|
271
|
+
expect(skip("/p/other/x.ts")).toBe(false);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("findInfraFiles — skip marker (#2519)", () => {
|
|
276
|
+
test.each([
|
|
277
|
+
["a line comment", `// ${SKIP_MARKER}\nprocess.exit(2);`],
|
|
278
|
+
["a line comment after a shebang", `#!/usr/bin/env -S node --import tsx\n// ${SKIP_MARKER}: a runner, not a declaration\nprocess.exit(2);`],
|
|
279
|
+
["a block comment", `/**\n * Runs an Op.\n * ${SKIP_MARKER}\n */\nprocess.exit(2);`],
|
|
280
|
+
])("skips a file carrying the marker in %s", async (_label, body) => {
|
|
281
|
+
await withTestDir(async (testDir) => {
|
|
282
|
+
await writeFile(join(testDir, "app.ts"), "export const app = {};");
|
|
283
|
+
await writeFile(join(testDir, "run.ts"), body);
|
|
284
|
+
const files = await findInfraFiles(testDir);
|
|
285
|
+
expect(files).toHaveLength(1);
|
|
286
|
+
expect(files[0]).toMatch(/app\.ts$/);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test.each([
|
|
291
|
+
["in a string", `export const s = "${SKIP_MARKER}";`],
|
|
292
|
+
["as a longer word", `// ${SKIP_MARKER}-not\nexport const a = {};`],
|
|
293
|
+
["past the head", `${"// padding\n".repeat(100)}// ${SKIP_MARKER}\nexport const a = {};`],
|
|
294
|
+
])("does not treat the marker %s as a skip", (_label, body) => {
|
|
295
|
+
expect(hasSkipMarker(body)).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
});
|