@akanjs/devkit 3.0.0-alpha.8 → 3.0.0-alpha.81
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 +15 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +47 -1
- package/aiEditor.ts +1 -1
- package/akanApp/BackendImportGraph.test.ts +120 -0
- package/akanApp/BackendImportGraph.ts +167 -0
- package/akanApp/akanApp.host.test.ts +16 -678
- package/akanApp/akanApp.host.ts +65 -562
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +412 -0
- package/akanApp/index.ts +2 -0
- package/akanConfig/akanConfig.test.ts +182 -14
- package/akanConfig/akanConfig.ts +132 -47
- package/akanConfig/types.ts +8 -0
- package/akanContext.ts +70 -14
- package/akanMcpContract.ts +25 -4
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +45 -21
- package/artifact/implicitRootLayout.test.ts +67 -0
- package/artifact/implicitRootLayout.ts +27 -7
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +340 -0
- package/biomeBase.ts +9 -0
- package/capacitorApp.ts +10 -0
- package/cloud/globalConfig.ts +9 -2
- package/commandDecorators/command.ts +30 -6
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +132 -5
- package/executors.ts +89 -14
- package/fileEditor.ts +19 -19
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +4 -0
- package/frontendBuild/buildRouteClient.test.ts +47 -24
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/clientEntryDiscovery.ts +2 -2
- package/frontendBuild/csrArtifactBuilder.ts +116 -84
- package/frontendBuild/cssCompiler.ts +123 -12
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/fontOptimizer.ts +37 -18
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/frontendBuild.test.ts +178 -10
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/index.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.ts +3 -3
- package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
- package/incrementalBuilder/incrementalBuilder.proc.ts +8 -4
- package/index.ts +0 -5
- package/integration/devStabilityHarness.ts +2 -10
- package/libSource.test.ts +109 -0
- package/libSource.ts +126 -0
- package/lint/__fixtures__/README.md +40 -0
- package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
- package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
- package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
- package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
- package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
- package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
- package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
- package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
- package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
- package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
- package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
- package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
- package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
- package/lint/gritRules.test.ts +178 -0
- package/lint/no-arbitrary-color.grit +1 -1
- package/lint/no-async-component-in-ui.grit +35 -0
- package/lint/no-bang-comment-in-client.grit +23 -10
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-deprecated-log-level.grit +17 -0
- package/lint/no-import-client-in-server.grit +48 -0
- package/lint/no-import-server-in-client.grit +45 -0
- package/lint/no-init-fetch-in-client.grit +47 -0
- package/lint/no-inline-color.grit +10 -8
- package/lint/no-interpolated-arbitrary-class.grit +3 -3
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-raw-palette-class.grit +3 -3
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/lint/non-scalar-props-restricted.grit +16 -7
- package/linter.test.ts +80 -0
- package/linter.ts +99 -25
- package/package.json +6 -6
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +116 -0
- package/qualityScanner.ts +89 -21
- package/recipeScanner.ts +4 -1
- package/repoIdentity.ts +42 -0
- package/scanInfo.ts +32 -26
- package/semver.test.ts +26 -0
- package/semver.ts +31 -0
- package/slicePlanner.test.ts +182 -0
- package/slicePlanner.ts +235 -0
- package/ssrScanner.test.ts +301 -0
- package/subspace.test.ts +418 -0
- package/subspace.ts +723 -0
- package/subspaceConfig.ts +76 -0
- package/transforms/asyncDefaultExportDetector.ts +103 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/transforms/transforms.test.ts +5 -5
- package/transforms/tsconfigPackageResolver.test.ts +230 -0
- package/tsconfig.json +6 -2
- package/typeChecker.ts +1 -1
- package/types.ts +1 -0
- package/ui/ScrollList.tsx +6 -8
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +49 -4
- package/builder.ts +0 -164
- package/extractDeps.ts +0 -86
- package/frontendBuild/styleGuard.test.ts +0 -165
- package/frontendBuild/styleGuard.ts +0 -322
- package/getCredentials.ts +0 -19
- package/getModelFileData.ts +0 -62
- package/src/capacitorApp.ts +0 -282
- package/streamAi.ts +0 -45
|
@@ -5,7 +5,7 @@ import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
|
5
5
|
import { computeRouteSeedIndex, type RouteSeedIndex, saveRouteSeedIndex } from "../artifact/routeSeedIndex";
|
|
6
6
|
import type { App } from "../commandDecorators";
|
|
7
7
|
import { ClientEntriesBundler } from "./clientEntriesBundler";
|
|
8
|
-
import { CssCompiler } from "./cssCompiler";
|
|
8
|
+
import { CssCompiler, type ImportedStylesheet } from "./cssCompiler";
|
|
9
9
|
import { FontOptimizer } from "./fontOptimizer";
|
|
10
10
|
import { PagesBundleBuilder } from "./pagesBundleBuilder";
|
|
11
11
|
import { RouteClientBuilder } from "./routeClientBuilder";
|
|
@@ -77,6 +77,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
77
77
|
branches: [...akanConfig.branches],
|
|
78
78
|
i18n: akanConfig.i18n,
|
|
79
79
|
imageConfig: akanConfig.images,
|
|
80
|
+
web: akanConfig.web,
|
|
80
81
|
deepLinkAssociations: Object.values(akanConfig.mobile.targets)
|
|
81
82
|
.filter((target) => (target.deepLinks?.domains?.length ?? 0) > 0)
|
|
82
83
|
.map((target) => ({
|
|
@@ -110,7 +111,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
110
111
|
const ssrBundle = await new ClientEntriesBundler({
|
|
111
112
|
app: this.#app,
|
|
112
113
|
entries: [rscSegmentOutletEntry],
|
|
113
|
-
...RouteClientBuilder.
|
|
114
|
+
...RouteClientBuilder.resolveSsrClientBundleOptions(this.#command),
|
|
114
115
|
outputSubdir: "client-ssr",
|
|
115
116
|
command: this.#command,
|
|
116
117
|
}).bundle();
|
|
@@ -192,7 +193,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
192
193
|
Object.entries(cssByBasePath).flatMap(([basePath, baseCssText]) => {
|
|
193
194
|
const cssText = [baseCssText, optimizedFonts.css].filter(Boolean).join("\n");
|
|
194
195
|
if (!cssText) return [];
|
|
195
|
-
return [this.#writeCssAsset(basePath, cssText)];
|
|
196
|
+
return [this.#writeCssAsset(basePath, cssText, cssCompiler.importedStylesheetsByBasePath[basePath] ?? [])];
|
|
196
197
|
}),
|
|
197
198
|
),
|
|
198
199
|
);
|
|
@@ -201,7 +202,7 @@ export class SsrBaseArtifactBuilder {
|
|
|
201
202
|
return { cssCompiler, optimizedFonts, cssAssets };
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
async #writeCssAsset(basePath: string, cssText: string) {
|
|
205
|
+
async #writeCssAsset(basePath: string, cssText: string, imported: ImportedStylesheet[]) {
|
|
205
206
|
const cssAssetName = basePath || "root";
|
|
206
207
|
const preparedCssText = await prepareCssAsset(this.#command, basePath, cssText);
|
|
207
208
|
const cssHash = Bun.hash(`${basePath}\n${preparedCssText}`).toString(36);
|
|
@@ -210,7 +211,23 @@ export class SsrBaseArtifactBuilder {
|
|
|
210
211
|
`/_akan/styles/${cssAssetName}-${cssHash}.css`,
|
|
211
212
|
];
|
|
212
213
|
await Bun.write(path.join(this.#absArtifactDir, cssRelPath), preparedCssText);
|
|
214
|
+
SsrBaseArtifactBuilder.#warnDroppedImports(this.#app, cssRelPath, preparedCssText, imported);
|
|
213
215
|
this.#app.verbose(`[base-artifact] wrote ${preparedCssText.length} bytes of CSS for ${basePath} -> ${cssRelPath}`);
|
|
214
216
|
return [basePath, { cssUrl, cssRelPath }] as const;
|
|
215
217
|
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Checked against the file written here rather than against the compiled text, because this is the stylesheet
|
|
221
|
+
* `base-artifact.json` points at and therefore the only one an SSR render serves. A declaration can survive
|
|
222
|
+
* the compile and still be missing from the asset — a build that ships CSS to the CSR bundle and not to the
|
|
223
|
+
* server is indistinguishable, in the browser, from a theme that was never written.
|
|
224
|
+
*/
|
|
225
|
+
static #warnDroppedImports(app: App, cssRelPath: string, css: string, imported: ImportedStylesheet[]) {
|
|
226
|
+
for (const { cssPath, declaredNames } of imported) {
|
|
227
|
+
if (declaredNames.length === 0 || declaredNames.some((name) => css.includes(`${name}:`))) continue;
|
|
228
|
+
app.logger.warn(
|
|
229
|
+
`[base-artifact] @import ${cssPath} declares ${declaredNames.length} custom propert${declaredNames.length === 1 ? "y" : "ies"} and none of them are in ${cssRelPath}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
216
233
|
}
|
|
@@ -1,29 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Reports the style contract that `akan lint` enforces, in the shape build/dev/lint share.
|
|
3
|
+
*
|
|
4
|
+
* Only contrast lives here. The vocabulary closure — raw palette classes, arbitrary colors, dropped
|
|
5
|
+
* daisyUI slots, inline color literals, interpolated arbitrary values — is enforced by the grit plugins
|
|
6
|
+
* in `lint/*.grit` during the biome run, so it is not re-scanned. Contrast cannot be a lint rule at all:
|
|
7
|
+
* it is arithmetic over resolved token *values*, which no syntactic pattern can reach.
|
|
5
8
|
*/
|
|
6
|
-
import type { StyleGuardViolation } from "./styleGuard";
|
|
7
9
|
import type { ThemeContrastViolation } from "./themeValidator";
|
|
8
10
|
|
|
9
11
|
export interface StyleContractViolations {
|
|
10
|
-
style: StyleGuardViolation[];
|
|
11
12
|
theme: ThemeContrastViolation[];
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
export const countBlocking = (
|
|
15
|
-
v.style.filter((s) => s.severity === "error").length + v.theme.length;
|
|
15
|
+
export const countBlocking = (violations: StyleContractViolations): number => violations.theme.length;
|
|
16
16
|
|
|
17
|
-
export const formatStyleContract = (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
for (const t of v.theme) {
|
|
25
|
-
lines.push(` [error] contrast ${t.scope} ${t.pair} = ${t.ratio}:1 (min ${t.threshold}:1)`);
|
|
26
|
-
lines.push(` → ${t.suggestion}`);
|
|
27
|
-
}
|
|
28
|
-
return lines.join("\n");
|
|
29
|
-
};
|
|
17
|
+
export const formatStyleContract = (violations: StyleContractViolations): string =>
|
|
18
|
+
violations.theme
|
|
19
|
+
.flatMap((theme) => [
|
|
20
|
+
` [error] contrast ${theme.scope} ${theme.pair} = ${theme.ratio}:1 (min ${theme.threshold}:1)`,
|
|
21
|
+
` → ${theme.suggestion}`,
|
|
22
|
+
])
|
|
23
|
+
.join("\n");
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* WCAG contrast checking over the semantic token pairs. No dependencies, pure functions, and no akanjs
|
|
3
|
+
* runtime import — it has to run inside the lint path.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* The `--x` / `--x-foreground` pairing is what makes the check possible at all: it says which two values
|
|
6
|
+
* are going to end up on top of each other. That is the guard against a generated palette shipping a site
|
|
7
|
+
* nobody can read.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Thresholds (WCAG 2.1):
|
|
10
|
+
* - body and primary surfaces (background, primary, secondary, accent, neutral, card, popover): 4.5:1,
|
|
11
|
+
* the AA floor for normal text
|
|
12
|
+
* - status and secondary pairs (info, success, warning, destructive, open, muted): 3:1, the AA floor for
|
|
13
|
+
* UI components and large text
|
|
14
|
+
* The shipped light/dark palette in styles.css clears all of them.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
export interface ThemeContrastViolation {
|
|
@@ -42,13 +46,13 @@ const PAIRS: PairDef[] = [
|
|
|
42
46
|
{ base: "open", fg: "open-foreground", threshold: 3 },
|
|
43
47
|
];
|
|
44
48
|
|
|
45
|
-
//
|
|
49
|
+
// Only these scopes are paired up; tokens scoped to something else (`.campaign-x`, …) are left alone.
|
|
46
50
|
const THEME_SCOPES = new Set([":root", '[data-theme="dark"]', '[data-theme="light"]']);
|
|
47
51
|
|
|
48
52
|
export type ThemeTokensByScope = Record<string, Record<string, string>>;
|
|
49
53
|
|
|
50
54
|
export class ThemeValidator {
|
|
51
|
-
/**
|
|
55
|
+
/** Extracts tokens from the css text and checks every scope it recognizes. */
|
|
52
56
|
validate(css: string): ThemeContrastViolation[] {
|
|
53
57
|
const tokensByScope = ThemeValidator.parseThemeTokens(css);
|
|
54
58
|
const violations: ThemeContrastViolation[] = [];
|
|
@@ -67,7 +71,7 @@ export class ThemeValidator {
|
|
|
67
71
|
if (!bg || !front) continue;
|
|
68
72
|
const bgRgb = ThemeValidator.parseHex(bg);
|
|
69
73
|
const fgRgb = ThemeValidator.parseHex(front);
|
|
70
|
-
if (!bgRgb || !fgRgb) continue; // var()
|
|
74
|
+
if (!bgRgb || !fgRgb) continue; // a var() or non-hex value has no ratio to compute
|
|
71
75
|
const ratio = ThemeValidator.contrastRatio(bgRgb, fgRgb);
|
|
72
76
|
if (ratio >= threshold) continue;
|
|
73
77
|
violations.push({
|
|
@@ -77,20 +81,21 @@ export class ThemeValidator {
|
|
|
77
81
|
foreground: front,
|
|
78
82
|
ratio: Math.round(ratio * 100) / 100,
|
|
79
83
|
threshold,
|
|
80
|
-
suggestion:
|
|
84
|
+
suggestion: `In ${scope}, --${base} (${bg}) against --${fg} (${front}) is ${ratio.toFixed(2)}:1, under the ${threshold}:1 minimum. Lighten or darken one of them until it clears.`,
|
|
81
85
|
});
|
|
82
86
|
}
|
|
83
87
|
return violations;
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
/**
|
|
87
|
-
* `:root` / `[data-theme="…"]`
|
|
88
|
-
* (`:root, [data-theme="dark"] { … }`)
|
|
89
|
-
*
|
|
91
|
+
* Reads `--token: value` out of `:root` / `[data-theme="…"]` blocks. A grouped selector
|
|
92
|
+
* (`:root, [data-theme="dark"] { … }`) distributes the same tokens to each selector, and a scope that
|
|
93
|
+
* appears twice keeps the later value — so passing framework css first and the app's second reflects the
|
|
94
|
+
* app's overrides.
|
|
90
95
|
*/
|
|
91
96
|
static parseThemeTokens(css: string): ThemeTokensByScope {
|
|
92
97
|
const result: ThemeTokensByScope = {};
|
|
93
|
-
//
|
|
98
|
+
// Flat rule blocks only; an at-rule (@theme, @keyframes) carries `@` in the selector and is skipped.
|
|
94
99
|
const blockRe = /(?:^|})\s*([^{}@]+?)\s*\{([^{}]*)\}/g;
|
|
95
100
|
for (const block of css.matchAll(blockRe)) {
|
|
96
101
|
const selectors = block[1].split(",").map((s) => s.trim());
|
|
@@ -109,11 +114,11 @@ export class ThemeValidator {
|
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
static #normalizeScope(selector: string): string {
|
|
112
|
-
//
|
|
117
|
+
// Quote normalization: [data-theme=dark] / [data-theme='dark'] -> [data-theme="dark"]
|
|
113
118
|
return selector.replace(/\[data-theme=['"]?([\w-]+)['"]?\]/g, '[data-theme="$1"]').trim();
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
/** #rgb / #rgba / #rrggbb / #rrggbbaa
|
|
121
|
+
/** #rgb / #rgba / #rrggbb / #rrggbbaa -> [r,g,b], alpha ignored. Null for anything not hex. */
|
|
117
122
|
static parseHex(value: string): [number, number, number] | null {
|
|
118
123
|
const v = value.trim();
|
|
119
124
|
if (!v.startsWith("#")) return null;
|
|
@@ -22,27 +22,25 @@ describe("prepareDevWatchBatch", () => {
|
|
|
22
22
|
expect(prepared.event.devPlan?.files).toEqual(prepared.files);
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
test.each([
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
indexSync: { changedFiles: [generatedIndex], errors: [] },
|
|
38
|
-
changePlanner: new DevChangePlanner({ workspaceRoot: root }),
|
|
39
|
-
});
|
|
25
|
+
test.each(["common", "srvkit", "ui", "webkit"])(
|
|
26
|
+
"keeps %s facet add/delete generated index in the same generation",
|
|
27
|
+
(facet) => {
|
|
28
|
+
const root = "/repo";
|
|
29
|
+
const changedFile = `${root}/libs/shared/${facet}/tmpExample.ts`;
|
|
30
|
+
const generatedIndex = `${root}/libs/shared/${facet}/index.ts`;
|
|
31
|
+
const prepared = prepareDevWatchBatch({
|
|
32
|
+
generation: 20,
|
|
33
|
+
batch: { files: [changedFile], kinds: new Set(["code"]) },
|
|
34
|
+
indexSync: { changedFiles: [generatedIndex], errors: [] },
|
|
35
|
+
changePlanner: new DevChangePlanner({ workspaceRoot: root }),
|
|
36
|
+
});
|
|
40
37
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
38
|
+
expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
|
|
39
|
+
expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
|
|
40
|
+
expect(prepared.event.devPlan?.roles).toContain("barrel");
|
|
41
|
+
expect(prepared.event.devPlan?.actions).toContain("sync-generated");
|
|
42
|
+
},
|
|
43
|
+
);
|
|
46
44
|
|
|
47
45
|
test("marks failed generated index sync as an error generation", () => {
|
|
48
46
|
const root = "/repo";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BuilderEvent, ChangeBatch } from "akanjs/server";
|
|
1
|
+
import type { BuilderEvent, ChangeBatch, DevChangePlan } from "akanjs/server";
|
|
2
2
|
import type { DevChangePlanner, GeneratedIndexSyncResult } from "../frontendBuild";
|
|
3
3
|
|
|
4
4
|
export interface PrepareDevWatchBatchOptions {
|
|
@@ -12,6 +12,8 @@ export interface PreparedDevWatchBatch {
|
|
|
12
12
|
files: string[];
|
|
13
13
|
kinds: ("code" | "css" | "config")[];
|
|
14
14
|
expandedBatch: ChangeBatch;
|
|
15
|
+
/** The same plan the event carries, where `devPlan` is optional — read it here, not off the event. */
|
|
16
|
+
devPlan: DevChangePlan;
|
|
15
17
|
event: Extract<BuilderEvent, { type: "invalidate" }>;
|
|
16
18
|
hasSyncErrors: boolean;
|
|
17
19
|
}
|
|
@@ -35,13 +37,15 @@ export const prepareDevWatchBatch = ({
|
|
|
35
37
|
});
|
|
36
38
|
|
|
37
39
|
if (indexSync.errors.length > 0 && !devPlan.actions.includes("report-error")) {
|
|
38
|
-
|
|
40
|
+
const withReport: DevChangePlan["actions"] = [...devPlan.actions, "report-error"];
|
|
41
|
+
devPlan.actions = withReport.sort();
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
return {
|
|
42
45
|
files,
|
|
43
46
|
kinds,
|
|
44
47
|
expandedBatch,
|
|
48
|
+
devPlan,
|
|
45
49
|
event: { type: "invalidate", kinds, files, generation, devPlan },
|
|
46
50
|
hasSyncErrors: indexSync.errors.length > 0,
|
|
47
51
|
};
|
|
@@ -200,7 +200,7 @@ export class IncrementalBuilderHost {
|
|
|
200
200
|
// one at a time, into the dev error page a recycle is supposed to be invisible to. `ready` the
|
|
201
201
|
// field is deliberately untouched: `onExit` reads it to tell a planned exit from a boot failure.
|
|
202
202
|
this.#status = "recycling";
|
|
203
|
-
this.logger.
|
|
203
|
+
this.logger.debug(`recycling builder pid=${proc.pid} (${reason})`);
|
|
204
204
|
this.#recycleTimer = setTimeout(() => {
|
|
205
205
|
this.#recycleTimer = null;
|
|
206
206
|
if (this.#proc !== proc) return;
|
|
@@ -185,7 +185,12 @@ class IncrementalBuilder {
|
|
|
185
185
|
if (this.#shuttingDown) return;
|
|
186
186
|
this.#shuttingDown = true;
|
|
187
187
|
const started = Date.now();
|
|
188
|
-
this.#logger.
|
|
188
|
+
this.#logger.debug(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
|
|
189
|
+
// Stopped before the drain, or a save landing mid-drain would enqueue a batch behind the queue tail
|
|
190
|
+
// this method already awaited — and `process.exit(0)` would cut that batch off partway through
|
|
191
|
+
// writing its artifacts. The replacement rebuilds every artifact from its boot build anyway, so the
|
|
192
|
+
// batch is not lost; a half-written one would be.
|
|
193
|
+
this.#watcher?.stop();
|
|
189
194
|
if (this.#cssRebuildTimer) {
|
|
190
195
|
// Only reachable if a css batch landed between the idle report and this request: the fresh
|
|
191
196
|
// boot build recompiles css from scratch anyway, so dropping the debounce loses nothing.
|
|
@@ -200,7 +205,7 @@ class IncrementalBuilder {
|
|
|
200
205
|
// flushed — a `css-updated` relayed milliseconds before this line would be dropped with no error
|
|
201
206
|
// anywhere, leaving the backend serving the previous bundle. See `BuilderChannel`.
|
|
202
207
|
const flushed = await BuilderChannel.drain();
|
|
203
|
-
this.#logger.
|
|
208
|
+
this.#logger.debug(
|
|
204
209
|
`drained in ${Date.now() - started}ms${flushed ? ` after flushing ${flushed} ipc write(s)` : ""}; exiting for recycle`,
|
|
205
210
|
);
|
|
206
211
|
process.exit(0);
|
|
@@ -303,13 +308,12 @@ class IncrementalBuilder {
|
|
|
303
308
|
//* them to the watcher so its verification scan does not read them back as a user edit and spend a
|
|
304
309
|
//* second generation rebuilding identical content.
|
|
305
310
|
await this.#watcher?.absorb([...autoImport.changedFiles, ...indexSync.changedFiles]);
|
|
306
|
-
const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
|
|
311
|
+
const { files, kinds, expandedBatch, devPlan, event, hasSyncErrors } = prepareDevWatchBatch({
|
|
307
312
|
generation,
|
|
308
313
|
batch,
|
|
309
314
|
indexSync,
|
|
310
315
|
changePlanner: this.#changePlanner,
|
|
311
316
|
});
|
|
312
|
-
const devPlan = event.devPlan;
|
|
313
317
|
this.#logger.verbose(
|
|
314
318
|
`[hmr] batch generation=${generation} kinds=${kinds.join(",")} files=${files.length} generated=${indexSync.changedFiles.length} roles=${devPlan.roles.join(",") || "(none)"} actions=${devPlan.actions.join(",") || "(none)"}`,
|
|
315
319
|
);
|
package/index.ts
CHANGED
|
@@ -22,19 +22,15 @@ export type * from "./applicationBuildRunner";
|
|
|
22
22
|
export type * from "./applicationReleasePackager";
|
|
23
23
|
export type * from "./applicationTestPreload";
|
|
24
24
|
export type * from "./artifact";
|
|
25
|
-
export type * from "./builder";
|
|
26
25
|
export type * from "./capacitorApp";
|
|
27
26
|
export type * from "./cloud";
|
|
28
27
|
export type * from "./commandDecorators";
|
|
29
28
|
export type * from "./createTunnel";
|
|
30
29
|
export type * from "./dependencyScanner";
|
|
31
30
|
export type * from "./executors";
|
|
32
|
-
export type * from "./extractDeps";
|
|
33
31
|
export type * from "./fileSys";
|
|
34
32
|
export type * from "./frontendBuild";
|
|
35
|
-
export type * from "./getCredentials";
|
|
36
33
|
export type * from "./getDirname";
|
|
37
|
-
export type * from "./getModelFileData";
|
|
38
34
|
export type * from "./getRelatedCnsts";
|
|
39
35
|
export type * from "./guideline";
|
|
40
36
|
export type * from "./incrementalBuilder";
|
|
@@ -46,7 +42,6 @@ export type * from "./recipeScanner";
|
|
|
46
42
|
export type * from "./scanInfo";
|
|
47
43
|
export type * from "./selectModel";
|
|
48
44
|
export type * from "./spinner";
|
|
49
|
-
export type * from "./streamAi";
|
|
50
45
|
export type * from "./transforms";
|
|
51
46
|
export type * from "./typeChecker";
|
|
52
47
|
export type * from "./types";
|
|
@@ -118,20 +118,12 @@ export default config;
|
|
|
118
118
|
),
|
|
119
119
|
this.writeFile(
|
|
120
120
|
"env/env.client.ts",
|
|
121
|
-
`
|
|
122
|
-
|
|
123
|
-
export const env = {
|
|
124
|
-
...getEnv(),
|
|
125
|
-
} as const;
|
|
121
|
+
`export const env = {} as const;
|
|
126
122
|
`,
|
|
127
123
|
),
|
|
128
124
|
this.writeFile(
|
|
129
125
|
"env/env.server.ts",
|
|
130
|
-
`
|
|
131
|
-
|
|
132
|
-
export const env = {
|
|
133
|
-
...getEnv(),
|
|
134
|
-
} as const;
|
|
126
|
+
`export const env = {} as const;
|
|
135
127
|
`,
|
|
136
128
|
),
|
|
137
129
|
this.writeFile(
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { Executor, LibExecutor, WorkspaceExecutor } from "./executors";
|
|
6
|
+
import { formatLibStatuses, LibSource } from "./libSource";
|
|
7
|
+
|
|
8
|
+
const tempRoots: string[] = [];
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const write = async (filePath: string, content: string) => {
|
|
15
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
16
|
+
await writeFile(filePath, content);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// `LibExecutor.from` memoises by name, so each fixture needs a name no other test has used.
|
|
20
|
+
const makeLib = async (libName: string) => {
|
|
21
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-libsource-"));
|
|
22
|
+
tempRoots.push(root);
|
|
23
|
+
await write(path.join(root, "package.json"), '{ "name": "workspace", "version": "0.0.1", "description": "workspace" }\n');
|
|
24
|
+
await write(path.join(root, ".gitignore"), "node_modules\n");
|
|
25
|
+
await write(path.join(root, `libs/${libName}/package.json`), `{ "name": "@${libName}", "version": "0.0.1" }\n`);
|
|
26
|
+
await write(path.join(root, `libs/${libName}/common/helper.ts`), "export const helper = 1;\n");
|
|
27
|
+
await write(path.join(root, `libs/${libName}/env/env.server.testing.ts`), "export const env = { key: 1 };\n");
|
|
28
|
+
|
|
29
|
+
const git = new Executor("fixture", root);
|
|
30
|
+
await git.spawn("git", ["init", "--quiet"]);
|
|
31
|
+
|
|
32
|
+
const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: `workspace-${libName}` });
|
|
33
|
+
const lib = LibExecutor.from(workspace, libName);
|
|
34
|
+
return { root, lib, source: new LibSource(lib) };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe("LibSource", () => {
|
|
38
|
+
test("reports an unstamped library", async () => {
|
|
39
|
+
const { source } = await makeLib("plain-lib");
|
|
40
|
+
const status = await source.status();
|
|
41
|
+
|
|
42
|
+
expect(status.drift).toBe("unstamped");
|
|
43
|
+
expect(status.stamp).toBeNull();
|
|
44
|
+
expect(status.hash).toMatch(/^[0-9a-f]{32}$/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("stamps the origin into package.json and reads back clean", async () => {
|
|
48
|
+
const { lib, source } = await makeLib("stamped-lib");
|
|
49
|
+
const stamp = await source.write({ origin: "akanjs", sha: "3.0.0" });
|
|
50
|
+
|
|
51
|
+
expect(stamp.origin).toBe("akanjs");
|
|
52
|
+
expect(await source.read()).toEqual(stamp);
|
|
53
|
+
expect((await source.status()).drift).toBe("clean");
|
|
54
|
+
|
|
55
|
+
const manifest = await lib.getPackageJson();
|
|
56
|
+
expect(manifest.name).toBe("@stamped-lib");
|
|
57
|
+
expect((manifest.akan as { source: { sha: string } }).source.sha).toBe("3.0.0");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("detects an edit to library source as drift", async () => {
|
|
61
|
+
const { root, source } = await makeLib("drift-lib");
|
|
62
|
+
await source.write({ origin: "akanjs", sha: "3.0.0" });
|
|
63
|
+
await write(path.join(root, "libs/drift-lib/common/helper.ts"), "export const helper = 2;\n");
|
|
64
|
+
|
|
65
|
+
expect((await source.status()).drift).toBe("drifted");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("survives every other akan write to the manifest", async () => {
|
|
69
|
+
const { lib, source } = await makeLib("merge-lib");
|
|
70
|
+
const stamp = await source.write({ origin: "akanjs", sha: "3.0.0" });
|
|
71
|
+
const manifest = await lib.getPackageJson();
|
|
72
|
+
await lib.setPackageJson({ ...manifest, dependencies: { lodash: "4.0.0" } });
|
|
73
|
+
|
|
74
|
+
expect(await source.read()).toEqual(stamp);
|
|
75
|
+
expect((await source.status()).drift).toBe("drifted");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("leaves env values out of the hash — they belong to the installing workspace", async () => {
|
|
79
|
+
const { root, source } = await makeLib("env-lib");
|
|
80
|
+
await source.write({ origin: "akanjs", sha: "3.0.0" });
|
|
81
|
+
await write(path.join(root, "libs/env-lib/env/env.server.testing.ts"), "export const env = { key: 999 };\n");
|
|
82
|
+
|
|
83
|
+
expect((await source.status()).drift).toBe("clean");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("formatLibStatuses", () => {
|
|
88
|
+
test("marks drifted libraries and counts them", () => {
|
|
89
|
+
const text = formatLibStatuses([
|
|
90
|
+
{
|
|
91
|
+
lib: "util",
|
|
92
|
+
drift: "clean",
|
|
93
|
+
hash: "a".repeat(32),
|
|
94
|
+
stamp: { origin: "akanjs", sha: "3.0.0", hash: "a".repeat(32), syncedAt: "now" },
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
lib: "shared",
|
|
98
|
+
drift: "drifted",
|
|
99
|
+
hash: "b".repeat(32),
|
|
100
|
+
stamp: { origin: "akanjs", sha: "3.0.0", hash: "a".repeat(32), syncedAt: "now" },
|
|
101
|
+
},
|
|
102
|
+
{ lib: "local", drift: "unstamped", hash: "c".repeat(32), stamp: null },
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
expect(text).toContain("DRIFTED libs/shared akanjs@3.0.0");
|
|
106
|
+
expect(text).toContain("unstamped libs/local no akan.source in package.json");
|
|
107
|
+
expect(text).toContain("drifted: 1 / 3");
|
|
108
|
+
});
|
|
109
|
+
});
|
package/libSource.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { LibExecutor } from "./executors";
|
|
2
|
+
import type { PackageJson } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface LibSourceStamp {
|
|
5
|
+
/** Where the copy came from: a git remote URL, or `akanjs` for the published package. */
|
|
6
|
+
origin: string;
|
|
7
|
+
/** The origin's commit sha, or the package version when the origin is a registry. */
|
|
8
|
+
sha: string;
|
|
9
|
+
/** Content hash of the library as installed, so a later edit is detectable without the origin. */
|
|
10
|
+
hash: string;
|
|
11
|
+
syncedAt: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type LibDrift = "clean" | "drifted" | "unstamped";
|
|
15
|
+
|
|
16
|
+
export interface LibStatus {
|
|
17
|
+
lib: string;
|
|
18
|
+
drift: LibDrift;
|
|
19
|
+
stamp: LibSourceStamp | null;
|
|
20
|
+
hash: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A library's origin, recorded in its own `package.json` under an `akan.source` key.
|
|
25
|
+
*
|
|
26
|
+
* The key rides `package.json` rather than a file of its own because every akan write to a library's
|
|
27
|
+
* manifest is a spread of the existing object (`LibExecutor.syncPackageJson`), so an unknown top-level
|
|
28
|
+
* key survives — while a new root file would have to be added to `libRootAllowedFiles` before
|
|
29
|
+
* `akan sync` stopped rejecting it.
|
|
30
|
+
*/
|
|
31
|
+
export class LibSource {
|
|
32
|
+
static readonly manifestKey = "akan";
|
|
33
|
+
/**
|
|
34
|
+
* Left out of the hash. `env/` holds per-deployment values that belong to the workspace the library
|
|
35
|
+
* was installed into, not to the origin, so an env edit is not drift.
|
|
36
|
+
*/
|
|
37
|
+
static readonly unhashedDirs = ["env"];
|
|
38
|
+
|
|
39
|
+
#lib: LibExecutor;
|
|
40
|
+
constructor(lib: LibExecutor) {
|
|
41
|
+
this.#lib = lib;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get #prefix() {
|
|
45
|
+
return `libs/${this.#lib.name}/`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#isHashed(file: string) {
|
|
49
|
+
const relative = file.slice(this.#prefix.length);
|
|
50
|
+
return !LibSource.unhashedDirs.includes(relative.split("/")[0] ?? "");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `package.json` cannot hash the stamp it carries, so the key comes off before hashing. */
|
|
54
|
+
async #hashableContent(file: string) {
|
|
55
|
+
const content = await this.#lib.workspace.readFile(file);
|
|
56
|
+
if (file !== `${this.#prefix}package.json`) return content;
|
|
57
|
+
const manifest = JSON.parse(content) as PackageJson;
|
|
58
|
+
delete manifest[LibSource.manifestKey];
|
|
59
|
+
return JSON.stringify(manifest);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Hash over the library's own files. Untracked files count: a freshly copied library is not committed
|
|
64
|
+
* yet, and its hash has to be the same one a later `status` recomputes.
|
|
65
|
+
*/
|
|
66
|
+
async computeHash() {
|
|
67
|
+
const files = (await this.#lib.workspace.listGitFiles([`libs/${this.#lib.name}`], { untracked: true })).filter(
|
|
68
|
+
(file) => this.#isHashed(file),
|
|
69
|
+
);
|
|
70
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
hasher.update(file);
|
|
73
|
+
hasher.update("\0");
|
|
74
|
+
hasher.update(await this.#hashableContent(file));
|
|
75
|
+
hasher.update("\0");
|
|
76
|
+
}
|
|
77
|
+
return hasher.digest("hex").slice(0, 32);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async read(): Promise<LibSourceStamp | null> {
|
|
81
|
+
const manifest = await this.#lib.getPackageJson();
|
|
82
|
+
const akan = manifest[LibSource.manifestKey] as { source?: LibSourceStamp } | undefined;
|
|
83
|
+
return akan?.source ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async write({ origin, sha }: Pick<LibSourceStamp, "origin" | "sha">) {
|
|
87
|
+
const [manifest, hash] = await Promise.all([this.#lib.getPackageJson(), this.computeHash()]);
|
|
88
|
+
const akan = (manifest[LibSource.manifestKey] ?? {}) as Record<string, unknown>;
|
|
89
|
+
const stamp: LibSourceStamp = { origin, sha, hash, syncedAt: new Date().toISOString() };
|
|
90
|
+
await this.#lib.setPackageJson({ ...manifest, [LibSource.manifestKey]: { ...akan, source: stamp } });
|
|
91
|
+
return stamp;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Writes the stamp only when it would change. `syncedAt` moves on every write, so an unconditional
|
|
96
|
+
* write leaves the manifest dirty and defeats an idempotent caller — the hash is computed with the
|
|
97
|
+
* stamp removed, so comparing it first is sound.
|
|
98
|
+
*/
|
|
99
|
+
async syncStamp({ origin, sha }: Pick<LibSourceStamp, "origin" | "sha">) {
|
|
100
|
+
const [current, hash] = await Promise.all([this.read(), this.computeHash()]);
|
|
101
|
+
if (current?.origin === origin && current.sha === sha && current.hash === hash)
|
|
102
|
+
return { stamp: current, changed: false };
|
|
103
|
+
return { stamp: await this.write({ origin, sha }), changed: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async status(): Promise<LibStatus> {
|
|
107
|
+
const [stamp, hash] = await Promise.all([this.read(), this.computeHash()]);
|
|
108
|
+
const drift = !stamp ? "unstamped" : stamp.hash === hash ? "clean" : "drifted";
|
|
109
|
+
return { lib: this.#lib.name, drift, stamp, hash };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function formatLibStatuses(statuses: LibStatus[]) {
|
|
114
|
+
const marks = { clean: "clean ", drifted: "DRIFTED ", unstamped: "unstamped" } as const;
|
|
115
|
+
const sections = [
|
|
116
|
+
"Akan Library Source Status",
|
|
117
|
+
"",
|
|
118
|
+
...statuses.map((status) => {
|
|
119
|
+
const origin = status.stamp ? `${status.stamp.origin}@${status.stamp.sha}` : "no akan.source in package.json";
|
|
120
|
+
return ` ${marks[status.drift]} libs/${status.lib} ${origin}`;
|
|
121
|
+
}),
|
|
122
|
+
"",
|
|
123
|
+
`drifted: ${statuses.filter((status) => status.drift === "drifted").length} / ${statuses.length}`,
|
|
124
|
+
];
|
|
125
|
+
return sections.join("\n");
|
|
126
|
+
}
|