@beignet/cli 0.0.48 → 0.0.50
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 +54 -0
- package/README.md +117 -22
- package/dist/analysis/contract-factories.d.ts +17 -0
- package/dist/analysis/contract-factories.d.ts.map +1 -0
- package/dist/analysis/contract-factories.js +176 -0
- package/dist/analysis/contract-factories.js.map +1 -0
- package/dist/analysis/layers.d.ts +5 -0
- package/dist/analysis/layers.d.ts.map +1 -0
- package/dist/analysis/layers.js +172 -0
- package/dist/analysis/layers.js.map +1 -0
- package/dist/analysis/port-wiring.d.ts +23 -0
- package/dist/analysis/port-wiring.d.ts.map +1 -0
- package/dist/analysis/port-wiring.js +379 -0
- package/dist/analysis/port-wiring.js.map +1 -0
- package/dist/analysis/source-index.d.ts +5 -0
- package/dist/analysis/source-index.d.ts.map +1 -1
- package/dist/analysis/source-index.js +171 -42
- package/dist/analysis/source-index.js.map +1 -1
- package/dist/analysis/workspace.d.ts +11 -4
- package/dist/analysis/workspace.d.ts.map +1 -1
- package/dist/analysis/workspace.js +78 -8
- package/dist/analysis/workspace.js.map +1 -1
- package/dist/app-map-changes.d.ts +103 -0
- package/dist/app-map-changes.d.ts.map +1 -0
- package/dist/app-map-changes.js +949 -0
- package/dist/app-map-changes.js.map +1 -0
- package/dist/app-map.d.ts.map +1 -1
- package/dist/app-map.js +311 -92
- package/dist/app-map.js.map +1 -1
- package/dist/check.js +37 -3
- package/dist/check.js.map +1 -1
- package/dist/explain.js +4 -4
- package/dist/explain.js.map +1 -1
- package/dist/git-changes.d.ts +30 -0
- package/dist/git-changes.d.ts.map +1 -0
- package/dist/git-changes.js +367 -0
- package/dist/git-changes.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -1
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +706 -186
- package/dist/inspect.js.map +1 -1
- package/dist/lib.d.ts +3 -0
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +1 -0
- package/dist/lib.js.map +1 -1
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +309 -487
- package/dist/lint.js.map +1 -1
- package/dist/make/inbox.d.ts.map +1 -1
- package/dist/make/inbox.js +11 -4
- package/dist/make/inbox.js.map +1 -1
- package/dist/make/payments.js +2 -2
- package/dist/make/shared.d.ts.map +1 -1
- package/dist/make/shared.js +31 -27
- package/dist/make/shared.js.map +1 -1
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +144 -8
- package/dist/make.js.map +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +43 -21
- package/dist/mcp.js.map +1 -1
- package/dist/provider-add.d.ts.map +1 -1
- package/dist/provider-add.js +142 -7
- package/dist/provider-add.js.map +1 -1
- package/dist/registry-edits.d.ts +7 -0
- package/dist/registry-edits.d.ts.map +1 -1
- package/dist/registry-edits.js +237 -53
- package/dist/registry-edits.js.map +1 -1
- package/dist/templates/agents.d.ts.map +1 -1
- package/dist/templates/agents.js +37 -1
- package/dist/templates/agents.js.map +1 -1
- package/dist/templates/base.d.ts.map +1 -1
- package/dist/templates/base.js +13 -2
- package/dist/templates/base.js.map +1 -1
- package/dist/templates/server.d.ts.map +1 -1
- package/dist/templates/server.js +2 -0
- package/dist/templates/server.js.map +1 -1
- package/dist/templates/shared.d.ts +1 -0
- package/dist/templates/shared.d.ts.map +1 -1
- package/dist/templates/shared.js +3 -2
- package/dist/templates/shared.js.map +1 -1
- package/package.json +7 -6
- package/skills/app-structure/SKILL.md +71 -7
- package/src/analysis/contract-factories.ts +256 -0
- package/src/analysis/layers.ts +266 -0
- package/src/analysis/port-wiring.ts +506 -0
- package/src/analysis/source-index.ts +225 -57
- package/src/analysis/workspace.ts +134 -9
- package/src/app-map-changes.ts +1462 -0
- package/src/app-map.ts +418 -109
- package/src/check.ts +51 -3
- package/src/explain.ts +4 -4
- package/src/git-changes.ts +511 -0
- package/src/index.ts +39 -1
- package/src/inspect.ts +1005 -238
- package/src/lib.ts +21 -0
- package/src/lint.ts +393 -643
- package/src/make/inbox.ts +11 -4
- package/src/make/payments.ts +2 -2
- package/src/make/shared.ts +31 -27
- package/src/make.ts +225 -8
- package/src/mcp.ts +51 -24
- package/src/provider-add.ts +186 -7
- package/src/registry-edits.ts +313 -63
- package/src/templates/agents.ts +37 -1
- package/src/templates/base.ts +13 -2
- package/src/templates/server.ts +2 -0
- package/src/templates/shared.ts +3 -2
package/src/check.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createPainter } from "./ansi.js";
|
|
4
4
|
import { detectPackageManager, spawnCommand } from "./db.js";
|
|
@@ -75,6 +75,7 @@ export async function checkApp(
|
|
|
75
75
|
options: CheckAppOptions = {},
|
|
76
76
|
): Promise<CheckAppResult> {
|
|
77
77
|
const targetDir = path.resolve(options.cwd ?? process.cwd());
|
|
78
|
+
const scripts = await readAppPackageScripts(targetDir);
|
|
78
79
|
const runner = await detectPackageManager(targetDir);
|
|
79
80
|
const steps: CheckStep[] = [];
|
|
80
81
|
const record = (step: CheckStep): void => {
|
|
@@ -99,7 +100,6 @@ export async function checkApp(
|
|
|
99
100
|
);
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
const scripts = await readPackageScripts(targetDir);
|
|
103
103
|
for (const script of scriptStepNames) {
|
|
104
104
|
options.signal?.throwIfAborted();
|
|
105
105
|
record(await scriptStep(targetDir, runner, script, scripts, options));
|
|
@@ -269,7 +269,7 @@ async function scriptStep(
|
|
|
269
269
|
};
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
async function
|
|
272
|
+
async function readAppPackageScripts(
|
|
273
273
|
targetDir: string,
|
|
274
274
|
): Promise<Record<string, string>> {
|
|
275
275
|
let source: string;
|
|
@@ -282,11 +282,59 @@ async function readPackageScripts(
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
const parsed = JSON.parse(source) as {
|
|
285
|
+
dependencies?: Record<string, string>;
|
|
286
|
+
optionalDependencies?: Record<string, string>;
|
|
285
287
|
scripts?: Record<string, string>;
|
|
286
288
|
};
|
|
289
|
+
const dependencies = {
|
|
290
|
+
...parsed.dependencies,
|
|
291
|
+
...parsed.optionalDependencies,
|
|
292
|
+
};
|
|
293
|
+
if (
|
|
294
|
+
!["@beignet/core", "@beignet/next", "@beignet/web"].some(
|
|
295
|
+
(packageName) => dependencies[packageName] !== undefined,
|
|
296
|
+
)
|
|
297
|
+
) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
"package.json does not declare a Beignet runtime dependency. Run beignet check from a Beignet app root that depends on @beignet/core, @beignet/next, or @beignet/web.",
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (!(await hasBeignetAppStructure(targetDir))) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
"package.json declares a Beignet runtime dependency, but no Beignet app structure was found. Run beignet check from an app root containing app-context.ts, beignet.config.*, or features/ with server/index.ts.",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
287
307
|
return parsed.scripts ?? {};
|
|
288
308
|
}
|
|
289
309
|
|
|
310
|
+
async function hasBeignetAppStructure(targetDir: string): Promise<boolean> {
|
|
311
|
+
const entries = await readdir(targetDir, { withFileTypes: true });
|
|
312
|
+
const entryByName = new Map(entries.map((entry) => [entry.name, entry]));
|
|
313
|
+
if (entryByName.get("app-context.ts")?.isFile()) return true;
|
|
314
|
+
if (
|
|
315
|
+
[
|
|
316
|
+
"beignet.config.ts",
|
|
317
|
+
"beignet.config.json",
|
|
318
|
+
"beignet.config.mjs",
|
|
319
|
+
"beignet.config.js",
|
|
320
|
+
].some((file) => entryByName.get(file)?.isFile())
|
|
321
|
+
) {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
if (
|
|
325
|
+
!entryByName.get("features")?.isDirectory() ||
|
|
326
|
+
!entryByName.get("server")?.isDirectory()
|
|
327
|
+
) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
return (await stat(path.join(targetDir, "server/index.ts"))).isFile();
|
|
333
|
+
} catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
290
338
|
async function preflightStep(
|
|
291
339
|
targetDir: string,
|
|
292
340
|
options: {
|
package/src/explain.ts
CHANGED
|
@@ -795,7 +795,7 @@ const conventionsByTargetKind = {
|
|
|
795
795
|
],
|
|
796
796
|
job: [
|
|
797
797
|
"Jobs represent background work and remain explicit at provider-backed execution boundaries.",
|
|
798
|
-
"Feature jobs register through central runtime or outbox registries before workers can dispatch them.",
|
|
798
|
+
"Feature jobs register through central runtime or outbox registries before workers can dispatch them; registration does not install the required jobs port.",
|
|
799
799
|
],
|
|
800
800
|
schedule: [
|
|
801
801
|
"Schedules represent time-based triggers and delegate work to application workflows.",
|
|
@@ -819,11 +819,11 @@ const conventionsByTargetKind = {
|
|
|
819
819
|
],
|
|
820
820
|
registry: [
|
|
821
821
|
"Registries explicitly compose feature declarations into the runtime surface inspected by doctor and app-map.",
|
|
822
|
-
"Central registries remain the source of truth for runtime wiring and integrity checks.",
|
|
822
|
+
"Central registries remain the source of truth for runtime wiring and integrity checks; outbox registries containing jobs depend on a bound or deferred jobs dispatcher.",
|
|
823
823
|
],
|
|
824
824
|
port: [
|
|
825
825
|
"Ports define app-facing dependency interfaces; infrastructure adapters provide their concrete implementations.",
|
|
826
|
-
"App ports wire through
|
|
826
|
+
"App ports wire through the configured port-wiring file and remain available to use cases through application context; deferred ports are verified during server startup.",
|
|
827
827
|
],
|
|
828
828
|
provider: [
|
|
829
829
|
"Ports define app-facing interfaces, providers adapt external systems, and app infrastructure wires them together.",
|
|
@@ -839,7 +839,7 @@ const conventionsByTargetKind = {
|
|
|
839
839
|
],
|
|
840
840
|
entrypoint: [
|
|
841
841
|
"Runtime entrypoints compose the server or expose thin framework adapters over registered routes.",
|
|
842
|
-
"Entrypoints reuse central context, routes, providers, and workflow registries instead of duplicating application behavior.",
|
|
842
|
+
"Entrypoints reuse central context, routes, providers, and workflow registries instead of duplicating application behavior; outbox drains require delivery ports before they claim messages.",
|
|
843
843
|
],
|
|
844
844
|
test: [
|
|
845
845
|
"Feature behavior tests stay under features/<feature>/tests and exercise use cases, routes, policies, workflows, and UI.",
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Stats } from "node:fs";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { lstat, readlink } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnCommand } from "./db.js";
|
|
7
|
+
|
|
8
|
+
const emptyTreeObjectIds = {
|
|
9
|
+
sha1: "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
|
|
10
|
+
sha256: "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321",
|
|
11
|
+
} as const;
|
|
12
|
+
const maxGitOutputBytes = 64 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
type GitCommandResult = Omit<
|
|
15
|
+
Awaited<ReturnType<typeof spawnCommand>>,
|
|
16
|
+
"stderr" | "stdout"
|
|
17
|
+
> & {
|
|
18
|
+
stderr: string;
|
|
19
|
+
stdout: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Git status values represented by changed-app mapping. */
|
|
23
|
+
export type GitChangedFileStatus =
|
|
24
|
+
| "added"
|
|
25
|
+
| "copied"
|
|
26
|
+
| "deleted"
|
|
27
|
+
| "modified"
|
|
28
|
+
| "renamed"
|
|
29
|
+
| "type-changed"
|
|
30
|
+
| "unmerged"
|
|
31
|
+
| "unknown"
|
|
32
|
+
| "untracked";
|
|
33
|
+
|
|
34
|
+
export type GitChangedFile = {
|
|
35
|
+
status: GitChangedFileStatus;
|
|
36
|
+
path: string;
|
|
37
|
+
oldPath?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Resolved local Git comparison used by changed-app mapping. */
|
|
41
|
+
export type GitChangeComparison =
|
|
42
|
+
| {
|
|
43
|
+
mode: "worktree";
|
|
44
|
+
comparisonCommit: string;
|
|
45
|
+
head?: string;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
mode: "base";
|
|
49
|
+
base: string;
|
|
50
|
+
baseCommit: string;
|
|
51
|
+
mergeBase: string;
|
|
52
|
+
head: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type GitChangeSet = {
|
|
56
|
+
repositoryRoot: string;
|
|
57
|
+
comparison: GitChangeComparison;
|
|
58
|
+
files: GitChangedFile[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export async function readGitChangeSet(options: {
|
|
62
|
+
cwd: string;
|
|
63
|
+
base?: string;
|
|
64
|
+
}): Promise<GitChangeSet> {
|
|
65
|
+
if (options.base !== undefined && options.base.length === 0) {
|
|
66
|
+
throw new Error("Git base must not be empty.");
|
|
67
|
+
}
|
|
68
|
+
const repositoryRoot = path.resolve(
|
|
69
|
+
await gitText(options.cwd, ["rev-parse", "--show-toplevel"]),
|
|
70
|
+
);
|
|
71
|
+
const head = await resolveHeadCommit(repositoryRoot);
|
|
72
|
+
|
|
73
|
+
let comparison: GitChangeComparison;
|
|
74
|
+
let comparisonCommit: string;
|
|
75
|
+
if (options.base !== undefined) {
|
|
76
|
+
if (!head) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Cannot compare --base ${JSON.stringify(options.base)} because this repository does not have a HEAD commit.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const baseCommit = await gitText(repositoryRoot, [
|
|
82
|
+
"rev-parse",
|
|
83
|
+
"--verify",
|
|
84
|
+
"--end-of-options",
|
|
85
|
+
`${options.base}^{commit}`,
|
|
86
|
+
]).catch(() => {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Could not resolve local Git base ${JSON.stringify(options.base)}. Fetch it before running Beignet; changed mapping never accesses the network.`,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
const mergeBase = await gitText(repositoryRoot, [
|
|
92
|
+
"merge-base",
|
|
93
|
+
baseCommit,
|
|
94
|
+
head,
|
|
95
|
+
]).catch(() => {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Could not find a merge base between ${JSON.stringify(options.base)} and HEAD.`,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
comparisonCommit = mergeBase;
|
|
101
|
+
comparison = {
|
|
102
|
+
mode: "base",
|
|
103
|
+
base: options.base,
|
|
104
|
+
baseCommit,
|
|
105
|
+
mergeBase,
|
|
106
|
+
head,
|
|
107
|
+
};
|
|
108
|
+
} else {
|
|
109
|
+
comparisonCommit = head ?? (await emptyTreeObjectId(repositoryRoot));
|
|
110
|
+
comparison = {
|
|
111
|
+
mode: "worktree",
|
|
112
|
+
comparisonCommit,
|
|
113
|
+
...(head ? { head } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const committed =
|
|
118
|
+
comparison.mode === "base"
|
|
119
|
+
? await gitDiffOutput(repositoryRoot, [
|
|
120
|
+
comparison.mergeBase,
|
|
121
|
+
comparison.head,
|
|
122
|
+
])
|
|
123
|
+
: "";
|
|
124
|
+
const staged = await gitDiffOutput(repositoryRoot, [
|
|
125
|
+
"--cached",
|
|
126
|
+
head ?? comparisonCommit,
|
|
127
|
+
]);
|
|
128
|
+
const unstaged = await gitDiffOutput(repositoryRoot, []);
|
|
129
|
+
const untracked = await gitOutput(repositoryRoot, [
|
|
130
|
+
"ls-files",
|
|
131
|
+
"--others",
|
|
132
|
+
"--exclude-standard",
|
|
133
|
+
"-z",
|
|
134
|
+
]);
|
|
135
|
+
const files = mergeChangedFiles(
|
|
136
|
+
parseNameStatus(committed),
|
|
137
|
+
parseNameStatus(staged),
|
|
138
|
+
parseNameStatus(unstaged),
|
|
139
|
+
parseUntracked(untracked),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return { repositoryRoot, comparison, files };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function emptyTreeObjectId(repositoryRoot: string): Promise<string> {
|
|
146
|
+
const objectFormat =
|
|
147
|
+
(await tryGitText(repositoryRoot, [
|
|
148
|
+
"rev-parse",
|
|
149
|
+
"--show-object-format=storage",
|
|
150
|
+
])) ?? "sha1";
|
|
151
|
+
const objectId =
|
|
152
|
+
emptyTreeObjectIds[objectFormat as keyof typeof emptyTreeObjectIds];
|
|
153
|
+
if (!objectId) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Unsupported Git object format ${JSON.stringify(objectFormat)}.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return objectId;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function hashGitChanges(
|
|
162
|
+
changeSet: Pick<GitChangeSet, "comparison" | "files" | "repositoryRoot">,
|
|
163
|
+
files = changeSet.files,
|
|
164
|
+
): Promise<string> {
|
|
165
|
+
const hash = createHash("sha256");
|
|
166
|
+
const indexEntries = await readGitIndexEntries(changeSet.repositoryRoot);
|
|
167
|
+
hash.update(JSON.stringify(changeSet.comparison));
|
|
168
|
+
hash.update("\0");
|
|
169
|
+
|
|
170
|
+
for (const file of [...files].sort(compareChangedFiles)) {
|
|
171
|
+
hash.update(
|
|
172
|
+
JSON.stringify({
|
|
173
|
+
status: file.status,
|
|
174
|
+
path: file.path,
|
|
175
|
+
oldPath: file.oldPath,
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
hash.update("\0");
|
|
179
|
+
for (const relativePath of uniquePaths([file.path, file.oldPath])) {
|
|
180
|
+
hash.update(relativePath);
|
|
181
|
+
hash.update("\0index\0");
|
|
182
|
+
hash.update(indexEntries.get(relativePath) ?? "missing");
|
|
183
|
+
hash.update("\0worktree\0");
|
|
184
|
+
await hashCurrentPath(hash, changeSet.repositoryRoot, relativePath);
|
|
185
|
+
hash.update("\0");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return `sha256:${hash.digest("hex")}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function readGitIndexEntries(
|
|
193
|
+
repositoryRoot: string,
|
|
194
|
+
): Promise<Map<string, string>> {
|
|
195
|
+
const output = await gitOutput(repositoryRoot, ["ls-files", "--stage", "-z"]);
|
|
196
|
+
const entries = new Map<string, string[]>();
|
|
197
|
+
for (const field of output.split("\0").filter(Boolean)) {
|
|
198
|
+
const separator = field.indexOf("\t");
|
|
199
|
+
if (separator === -1) {
|
|
200
|
+
throw new Error("Git returned an incomplete index entry.");
|
|
201
|
+
}
|
|
202
|
+
const metadata = field.slice(0, separator);
|
|
203
|
+
const file = normalizeGitPath(field.slice(separator + 1));
|
|
204
|
+
const values = entries.get(file) ?? [];
|
|
205
|
+
values.push(metadata);
|
|
206
|
+
entries.set(file, values);
|
|
207
|
+
}
|
|
208
|
+
return new Map(
|
|
209
|
+
[...entries].map(([file, values]) => [file, [...values].sort().join("\0")]),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function hashCurrentPath(
|
|
214
|
+
hash: ReturnType<typeof createHash>,
|
|
215
|
+
repositoryRoot: string,
|
|
216
|
+
relativePath: string,
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
const absolutePath = path.join(repositoryRoot, relativePath);
|
|
219
|
+
let stats: Stats;
|
|
220
|
+
try {
|
|
221
|
+
stats = await lstat(absolutePath);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
224
|
+
hash.update("missing");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (stats.isSymbolicLink()) {
|
|
231
|
+
hash.update("symlink\0");
|
|
232
|
+
hash.update(String(stats.mode));
|
|
233
|
+
hash.update("\0");
|
|
234
|
+
hash.update(await readlink(absolutePath));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (stats.isDirectory()) {
|
|
238
|
+
hash.update("directory\0");
|
|
239
|
+
const head = await tryGitText(absolutePath, [
|
|
240
|
+
"rev-parse",
|
|
241
|
+
"--verify",
|
|
242
|
+
"HEAD^{commit}",
|
|
243
|
+
]);
|
|
244
|
+
const status = await tryGitOutput(absolutePath, [
|
|
245
|
+
"status",
|
|
246
|
+
"--porcelain=v1",
|
|
247
|
+
"-z",
|
|
248
|
+
"--untracked-files=all",
|
|
249
|
+
]);
|
|
250
|
+
hash.update(head ?? "unknown-head");
|
|
251
|
+
hash.update("\0");
|
|
252
|
+
hash.update(status ?? "unknown-status");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (!stats.isFile()) {
|
|
256
|
+
hash.update("special");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
hash.update("file\0");
|
|
261
|
+
hash.update(String(stats.mode));
|
|
262
|
+
hash.update("\0");
|
|
263
|
+
for await (const chunk of createReadStream(absolutePath)) {
|
|
264
|
+
hash.update(chunk as Buffer);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function parseNameStatus(output: string): GitChangedFile[] {
|
|
269
|
+
if (!output) return [];
|
|
270
|
+
const fields = output.split("\0");
|
|
271
|
+
const files: GitChangedFile[] = [];
|
|
272
|
+
for (let index = 0; index < fields.length; ) {
|
|
273
|
+
const rawStatus = fields[index++];
|
|
274
|
+
if (!rawStatus) continue;
|
|
275
|
+
const status = changedFileStatus(rawStatus[0]);
|
|
276
|
+
if (status === "renamed" || status === "copied") {
|
|
277
|
+
const oldPath = fields[index++];
|
|
278
|
+
const nextPath = fields[index++];
|
|
279
|
+
if (!oldPath || !nextPath) {
|
|
280
|
+
throw new Error("Git returned an incomplete rename or copy record.");
|
|
281
|
+
}
|
|
282
|
+
files.push({
|
|
283
|
+
status,
|
|
284
|
+
oldPath: normalizeGitPath(oldPath),
|
|
285
|
+
path: normalizeGitPath(nextPath),
|
|
286
|
+
});
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const file = fields[index++];
|
|
290
|
+
if (!file)
|
|
291
|
+
throw new Error("Git returned an incomplete changed-file record.");
|
|
292
|
+
files.push({ status, path: normalizeGitPath(file) });
|
|
293
|
+
}
|
|
294
|
+
return files;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function parseUntracked(output: string): GitChangedFile[] {
|
|
298
|
+
return output
|
|
299
|
+
.split("\0")
|
|
300
|
+
.filter(Boolean)
|
|
301
|
+
.map((file) => ({ status: "untracked", path: normalizeGitPath(file) }));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function mergeChangedFiles(...groups: GitChangedFile[][]): GitChangedFile[] {
|
|
305
|
+
const files = new Map<string, GitChangedFile>();
|
|
306
|
+
for (const group of groups) {
|
|
307
|
+
for (const file of group) {
|
|
308
|
+
if (file.status === "renamed" && file.oldPath) {
|
|
309
|
+
const source = files.get(file.oldPath);
|
|
310
|
+
if (source) {
|
|
311
|
+
files.delete(file.oldPath);
|
|
312
|
+
const composed = composeRename(source, file);
|
|
313
|
+
files.set(
|
|
314
|
+
composed.path,
|
|
315
|
+
mergeSameDestination(files.get(composed.path), composed),
|
|
316
|
+
);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
files.set(file.path, mergeSameDestination(files.get(file.path), file));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return [...files.values()].sort(compareChangedFiles);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function composeRename(
|
|
327
|
+
source: GitChangedFile,
|
|
328
|
+
rename: GitChangedFile,
|
|
329
|
+
): GitChangedFile {
|
|
330
|
+
if (source.status === "renamed" && source.oldPath) {
|
|
331
|
+
return rename.path === source.oldPath
|
|
332
|
+
? source
|
|
333
|
+
: { status: "renamed", oldPath: source.oldPath, path: rename.path };
|
|
334
|
+
}
|
|
335
|
+
if (source.status === "copied" && source.oldPath) {
|
|
336
|
+
return { status: "copied", oldPath: source.oldPath, path: rename.path };
|
|
337
|
+
}
|
|
338
|
+
if (source.status === "added" || source.status === "untracked") {
|
|
339
|
+
return { status: source.status, path: rename.path };
|
|
340
|
+
}
|
|
341
|
+
return rename;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function mergeSameDestination(
|
|
345
|
+
previous: GitChangedFile | undefined,
|
|
346
|
+
next: GitChangedFile,
|
|
347
|
+
): GitChangedFile {
|
|
348
|
+
if (
|
|
349
|
+
previous &&
|
|
350
|
+
(previous.status === "added" ||
|
|
351
|
+
previous.status === "copied" ||
|
|
352
|
+
previous.status === "renamed" ||
|
|
353
|
+
previous.status === "untracked") &&
|
|
354
|
+
next.status !== "unmerged" &&
|
|
355
|
+
next.status !== "unknown"
|
|
356
|
+
) {
|
|
357
|
+
return previous;
|
|
358
|
+
}
|
|
359
|
+
return next;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function uniquePaths(paths: Array<string | undefined>): string[] {
|
|
363
|
+
return [...new Set(paths.filter((file): file is string => Boolean(file)))];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function changedFileStatus(status: string | undefined): GitChangedFileStatus {
|
|
367
|
+
switch (status) {
|
|
368
|
+
case "A":
|
|
369
|
+
return "added";
|
|
370
|
+
case "C":
|
|
371
|
+
return "copied";
|
|
372
|
+
case "D":
|
|
373
|
+
return "deleted";
|
|
374
|
+
case "M":
|
|
375
|
+
return "modified";
|
|
376
|
+
case "R":
|
|
377
|
+
return "renamed";
|
|
378
|
+
case "T":
|
|
379
|
+
return "type-changed";
|
|
380
|
+
case "U":
|
|
381
|
+
return "unmerged";
|
|
382
|
+
default:
|
|
383
|
+
return "unknown";
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function compareChangedFiles(
|
|
388
|
+
left: GitChangedFile,
|
|
389
|
+
right: GitChangedFile,
|
|
390
|
+
): number {
|
|
391
|
+
return `${left.path}\0${left.oldPath ?? ""}\0${left.status}`.localeCompare(
|
|
392
|
+
`${right.path}\0${right.oldPath ?? ""}\0${right.status}`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function normalizeGitPath(file: string): string {
|
|
397
|
+
return process.platform === "win32" ? file.replaceAll("\\", "/") : file;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function gitText(cwd: string, args: readonly string[]): Promise<string> {
|
|
401
|
+
return (await gitOutput(cwd, args)).trim();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function resolveHeadCommit(
|
|
405
|
+
repositoryRoot: string,
|
|
406
|
+
): Promise<string | undefined> {
|
|
407
|
+
const head = await runGit(repositoryRoot, [
|
|
408
|
+
"rev-parse",
|
|
409
|
+
"--verify",
|
|
410
|
+
"HEAD^{commit}",
|
|
411
|
+
]);
|
|
412
|
+
if (head.exitCode === 0) return head.stdout.trim();
|
|
413
|
+
|
|
414
|
+
const symbolicHead = await runGit(repositoryRoot, [
|
|
415
|
+
"symbolic-ref",
|
|
416
|
+
"--quiet",
|
|
417
|
+
"HEAD",
|
|
418
|
+
]);
|
|
419
|
+
if (symbolicHead.exitCode !== 0) {
|
|
420
|
+
throw gitCommandError(["rev-parse", "--verify", "HEAD^{commit}"], head);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const ref = symbolicHead.stdout.trim();
|
|
424
|
+
const refExists = await runGit(repositoryRoot, [
|
|
425
|
+
"show-ref",
|
|
426
|
+
"--verify",
|
|
427
|
+
"--quiet",
|
|
428
|
+
ref,
|
|
429
|
+
]);
|
|
430
|
+
if (refExists.exitCode === 1) return undefined;
|
|
431
|
+
throw gitCommandError(["rev-parse", "--verify", "HEAD^{commit}"], head);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function tryGitText(
|
|
435
|
+
cwd: string,
|
|
436
|
+
args: readonly string[],
|
|
437
|
+
): Promise<string | undefined> {
|
|
438
|
+
try {
|
|
439
|
+
return await gitText(cwd, args);
|
|
440
|
+
} catch {
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function tryGitOutput(
|
|
446
|
+
cwd: string,
|
|
447
|
+
args: readonly string[],
|
|
448
|
+
): Promise<string | undefined> {
|
|
449
|
+
try {
|
|
450
|
+
return await gitOutput(cwd, args);
|
|
451
|
+
} catch {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function gitOutput(
|
|
457
|
+
cwd: string,
|
|
458
|
+
args: readonly string[],
|
|
459
|
+
): Promise<string> {
|
|
460
|
+
const result = await runGit(cwd, args);
|
|
461
|
+
if (result.exitCode !== 0) throw gitCommandError(args, result);
|
|
462
|
+
return result.stdout;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function runGit(
|
|
466
|
+
cwd: string,
|
|
467
|
+
args: readonly string[],
|
|
468
|
+
): Promise<GitCommandResult> {
|
|
469
|
+
const result = await spawnCommand("git", args, cwd, {
|
|
470
|
+
captureOutput: true,
|
|
471
|
+
maxOutputBytes: maxGitOutputBytes,
|
|
472
|
+
});
|
|
473
|
+
if (result.outputTruncated) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
`Git output exceeded ${maxGitOutputBytes} bytes; changed mapping cannot produce a complete report.`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
...result,
|
|
480
|
+
stdout: result.stdout ?? "",
|
|
481
|
+
stderr: result.stderr ?? "",
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function gitCommandError(
|
|
486
|
+
args: readonly string[],
|
|
487
|
+
result: GitCommandResult,
|
|
488
|
+
): Error {
|
|
489
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
490
|
+
return new Error(
|
|
491
|
+
`Git command failed: git ${args.join(" ")}${detail ? `\n${detail}` : ""}`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function gitDiffOutput(
|
|
496
|
+
repositoryRoot: string,
|
|
497
|
+
comparisonArgs: readonly string[],
|
|
498
|
+
): Promise<string> {
|
|
499
|
+
return gitOutput(repositoryRoot, [
|
|
500
|
+
"diff",
|
|
501
|
+
"--no-ext-diff",
|
|
502
|
+
"--no-textconv",
|
|
503
|
+
"--name-status",
|
|
504
|
+
"-z",
|
|
505
|
+
"--find-renames=50%",
|
|
506
|
+
"--find-copies=50%",
|
|
507
|
+
"--find-copies-harder",
|
|
508
|
+
...comparisonArgs,
|
|
509
|
+
"--",
|
|
510
|
+
]);
|
|
511
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -120,6 +120,8 @@ type RoutesFlags = {
|
|
|
120
120
|
type MapFlags = RoutesFlags & {
|
|
121
121
|
feature?: string;
|
|
122
122
|
kind?: readonly AppMapNodeKind[];
|
|
123
|
+
changed?: boolean;
|
|
124
|
+
base?: string;
|
|
123
125
|
};
|
|
124
126
|
|
|
125
127
|
type ExplainFlags = RoutesFlags;
|
|
@@ -391,6 +393,15 @@ const routesFlagParameters = {
|
|
|
391
393
|
|
|
392
394
|
const mapFlagParameters = {
|
|
393
395
|
json: jsonFlag,
|
|
396
|
+
changed: {
|
|
397
|
+
kind: "boolean",
|
|
398
|
+
optional: true,
|
|
399
|
+
withNegated: false,
|
|
400
|
+
brief: "Map the current Git change set to potentially affected concepts.",
|
|
401
|
+
},
|
|
402
|
+
base: parsedStringFlag(
|
|
403
|
+
"Include committed changes since the merge base of this local Git ref. Requires --changed.",
|
|
404
|
+
),
|
|
394
405
|
feature: parsedStringFlag(
|
|
395
406
|
"Project one feature and its direct relationships.",
|
|
396
407
|
),
|
|
@@ -889,7 +900,7 @@ const mapCommand = buildCommand<MapFlags, [], CliContext>({
|
|
|
889
900
|
docs: {
|
|
890
901
|
brief: "Map the app's architecture and registered workflows.",
|
|
891
902
|
fullDescription:
|
|
892
|
-
"Build a deterministic graph of features, HTTP contracts, routes, use cases, policies, workflows, ports, providers, OpenAPI, tests, dependencies, and doctor findings.",
|
|
903
|
+
"Build a deterministic graph of features, HTTP contracts, routes, use cases, policies, workflows, ports, providers, OpenAPI, tests, dependencies, and doctor findings. Use --changed for a bounded, report-only semantic map of the current Git change set.",
|
|
893
904
|
},
|
|
894
905
|
parameters: {
|
|
895
906
|
flags: mapFlagParameters,
|
|
@@ -898,8 +909,35 @@ const mapCommand = buildCommand<MapFlags, [], CliContext>({
|
|
|
898
909
|
const { formatAppMap, mapApp, projectAppMap } = await import(
|
|
899
910
|
"./app-map.js"
|
|
900
911
|
);
|
|
912
|
+
const { formatAppChangeImpact, mapAppChanges } = await import(
|
|
913
|
+
"./app-map-changes.js"
|
|
914
|
+
);
|
|
901
915
|
|
|
902
916
|
return async function runMap(this: CliContext, flags: MapFlags) {
|
|
917
|
+
if (flags.base !== undefined && !flags.changed) {
|
|
918
|
+
throw new Error("map --base requires --changed.");
|
|
919
|
+
}
|
|
920
|
+
if (
|
|
921
|
+
flags.changed &&
|
|
922
|
+
(flags.feature !== undefined || flags.kind !== undefined)
|
|
923
|
+
) {
|
|
924
|
+
throw new Error(
|
|
925
|
+
"map --changed cannot be combined with --feature or --kind.",
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
if (flags.changed) {
|
|
929
|
+
const changed = await mapAppChanges({
|
|
930
|
+
cwd: flags.cwd,
|
|
931
|
+
base: flags.base,
|
|
932
|
+
});
|
|
933
|
+
writeOutput(
|
|
934
|
+
this,
|
|
935
|
+
flags.json
|
|
936
|
+
? JSON.stringify(changed, null, 2)
|
|
937
|
+
: formatAppChangeImpact(changed),
|
|
938
|
+
);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
903
941
|
const result = projectAppMap(
|
|
904
942
|
await mapApp({ cwd: flags.cwd, strict: true }),
|
|
905
943
|
{
|