karajan-code 3.5.0 → 3.6.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/README.md +1 -1
- package/package.json +13 -1
- package/packages/ai-trash/README.md +37 -0
- package/packages/ai-trash/bin/kj-trash.js +10 -0
- package/packages/ai-trash/package.json +43 -0
- package/packages/ai-trash/src/cli.js +198 -0
- package/packages/ai-trash/src/destructive-parser.js +74 -0
- package/packages/ai-trash/src/git-snapshot.js +128 -0
- package/packages/ai-trash/src/hook.js +137 -0
- package/packages/ai-trash/src/index.js +14 -0
- package/packages/ai-trash/src/install.js +52 -0
- package/packages/ai-trash/src/logger.js +60 -0
- package/packages/ai-trash/src/manifest.js +91 -0
- package/packages/ai-trash/src/permissions.js +26 -0
- package/packages/ai-trash/src/snapshot.js +66 -0
- package/packages/ai-trash/src/ulid.js +66 -0
- package/scripts/verify-pack.mjs +14 -1
- package/src/checks/ai-trash.js +1 -1
- package/src/cli/register-meta.js +8 -0
- package/src/cli.js +15 -1
- package/src/commands/harden.js +57 -2
- package/src/commands/init.js +77 -45
- package/src/harden/advisory.js +168 -0
- package/src/harden/config-templates.js +5 -0
- package/src/harden/hook-commands.js +5 -0
- package/src/harden/interactive.js +76 -0
- package/src/harden/stack-roots.js +35 -4
- package/src/harden/workflow-templates.js +8 -0
- package/src/utils/squeezr-detect.js +4 -1
package/src/commands/init.js
CHANGED
|
@@ -91,17 +91,29 @@ async function askAgents(wizard, available, config, logger) {
|
|
|
91
91
|
* `available.length === 1` short-circuits: every role uses the only CLI
|
|
92
92
|
* available, no point asking.
|
|
93
93
|
*/
|
|
94
|
-
async function askPerRoleProviders(wizard, available, config, { coder, reviewer }, logger) {
|
|
94
|
+
async function askPerRoleProviders(wizard, available, config, { coder, reviewer }, logger, ask = true) {
|
|
95
95
|
if (available.length <= 1) return;
|
|
96
96
|
|
|
97
|
-
logger.info("");
|
|
98
|
-
logger.info("Configure provider per role (Enter accepts the default):");
|
|
99
|
-
|
|
100
97
|
// Make sure containers exist for every role/pipeline key we touch —
|
|
101
98
|
// older configs may have a sparser shape than DEFAULTS.
|
|
102
99
|
config.roles = config.roles || {};
|
|
103
100
|
config.pipeline = config.pipeline || {};
|
|
104
101
|
|
|
102
|
+
// KJC-TSK-0571: per-role provider selection is advanced. By default every
|
|
103
|
+
// role inherits its provider from coder/reviewer — a newcomer has no context
|
|
104
|
+
// to pick an agent for "refactorer" or "perf". Only init the containers.
|
|
105
|
+
if (!ask) {
|
|
106
|
+
for (const role of PER_ROLE_QUESTIONS) {
|
|
107
|
+
config.roles[role.key] = config.roles[role.key] || { provider: null, model: null };
|
|
108
|
+
if (role.pipelineKey) config.pipeline[role.pipelineKey] = config.pipeline[role.pipelineKey] || { enabled: false };
|
|
109
|
+
}
|
|
110
|
+
logger.info(" Agent routing: every role inherits from your coder/reviewer (default).");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
logger.info("");
|
|
115
|
+
logger.info("Configure provider per role (Enter accepts the default):");
|
|
116
|
+
|
|
105
117
|
for (const role of PER_ROLE_QUESTIONS) {
|
|
106
118
|
config.roles[role.key] = config.roles[role.key] || { provider: null, model: null };
|
|
107
119
|
if (role.pipelineKey) {
|
|
@@ -150,14 +162,14 @@ async function askFallbackChain(wizard, available, config, { coder, reviewer },
|
|
|
150
162
|
if (available.length < 2) return;
|
|
151
163
|
|
|
152
164
|
logger.info("");
|
|
153
|
-
logger.info("Plan B — fallback
|
|
165
|
+
logger.info("Plan B — automatic fallback when a provider runs out of credits:");
|
|
154
166
|
const enable = await wizard.confirm(
|
|
155
|
-
"
|
|
167
|
+
" Configure a fallback? (default 12h; if the cooldown exceeds it, switch to the alternate provider)",
|
|
156
168
|
true
|
|
157
169
|
);
|
|
158
170
|
if (!enable) return;
|
|
159
171
|
|
|
160
|
-
const maxHoursRaw = await wizard.input("
|
|
172
|
+
const maxHoursRaw = await wizard.input(" Max wait before falling back (hours):", "12");
|
|
161
173
|
const maxHours = Number.parseFloat(maxHoursRaw) || 12;
|
|
162
174
|
|
|
163
175
|
config.roles = config.roles || {};
|
|
@@ -168,18 +180,18 @@ async function askFallbackChain(wizard, available, config, { coder, reviewer },
|
|
|
168
180
|
];
|
|
169
181
|
for (const role of KEY_ROLES) {
|
|
170
182
|
config.roles[role.key] = config.roles[role.key] || {};
|
|
171
|
-
const opts = [{ label: "
|
|
183
|
+
const opts = [{ label: "No fallback (hibernate)", value: "__none__", available: true }];
|
|
172
184
|
for (const a of available) {
|
|
173
185
|
if (a.name === role.primary) continue;
|
|
174
186
|
opts.push({ label: a.name, value: a.name, available: true });
|
|
175
187
|
}
|
|
176
|
-
const choice = await wizard.select(` Fallback
|
|
188
|
+
const choice = await wizard.select(` Fallback for ${role.label} when ${role.primary} runs out of credits:`, opts);
|
|
177
189
|
if (choice === "__none__") {
|
|
178
190
|
delete config.roles[role.key].fallback;
|
|
179
|
-
logger.info(` -> ${role.key}:
|
|
191
|
+
logger.info(` -> ${role.key}: no fallback (will hibernate)`);
|
|
180
192
|
} else {
|
|
181
193
|
config.roles[role.key].fallback = { provider: choice, max_wait_hours: maxHours };
|
|
182
|
-
logger.info(` -> ${role.key}: ${role.primary} → ${choice} (
|
|
194
|
+
logger.info(` -> ${role.key}: ${role.primary} → ${choice} (after ${maxHours}h)`);
|
|
183
195
|
}
|
|
184
196
|
}
|
|
185
197
|
}
|
|
@@ -206,7 +218,7 @@ async function askLanguages(wizard, config, logger) {
|
|
|
206
218
|
...allLangEntries.filter((o) => o.value !== detectedLocale),
|
|
207
219
|
];
|
|
208
220
|
|
|
209
|
-
const pipelineLang = await wizard.select("
|
|
221
|
+
const pipelineLang = await wizard.select("Language for agent messages and reports:", languageOptions);
|
|
210
222
|
config.language = pipelineLang;
|
|
211
223
|
logger.info(` -> Pipeline language: ${pipelineLang}`);
|
|
212
224
|
|
|
@@ -214,14 +226,14 @@ async function askLanguages(wizard, config, logger) {
|
|
|
214
226
|
...allLangEntries.filter((o) => o.value === pipelineLang),
|
|
215
227
|
...allLangEntries.filter((o) => o.value !== pipelineLang),
|
|
216
228
|
];
|
|
217
|
-
const huLang = await wizard.select("
|
|
229
|
+
const huLang = await wizard.select("Language for user stories (HUs):", huLangOptions);
|
|
218
230
|
config.hu_language = huLang;
|
|
219
|
-
logger.info(` ->
|
|
231
|
+
logger.info(` -> User-story language: ${huLang}`);
|
|
220
232
|
}
|
|
221
233
|
|
|
222
234
|
async function askGitAutomation(wizard, config, logger) {
|
|
223
235
|
config.git = config.git || {};
|
|
224
|
-
const autoCommit = await wizard.confirm("Auto-commit per
|
|
236
|
+
const autoCommit = await wizard.confirm("Auto-commit per user story? (recommended for `kj run` so commits show up in git log)", false);
|
|
225
237
|
config.git.auto_commit = autoCommit;
|
|
226
238
|
logger.info(` -> auto_commit: ${autoCommit}`);
|
|
227
239
|
|
|
@@ -268,30 +280,20 @@ async function askBoardSecurity(wizard, config, logger) {
|
|
|
268
280
|
/**
|
|
269
281
|
* Explicit, opt-in telemetry consent. We do NOT inherit a "Karajan-installed
|
|
270
282
|
* users want to help" assumption — silence and missing config keys both mean
|
|
271
|
-
* "no". The prompt is
|
|
272
|
-
*
|
|
273
|
-
* they've set `config.language`. See src/utils/telemetry.js and the
|
|
283
|
+
* "no". The prompt is English to match the rest of the wizard (KJC-BUG-0088).
|
|
284
|
+
* See src/utils/telemetry.js and the
|
|
274
285
|
* "Privacy & Telemetry" section of README.md for what gets sent.
|
|
275
286
|
*/
|
|
276
287
|
async function askTelemetry(wizard, config, logger) {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
},
|
|
286
|
-
en: {
|
|
287
|
-
question: "Help improve Karajan by sending anonymous telemetry to the project?",
|
|
288
|
-
detail: " (version, OS, commands, pipeline duration — no code, no tasks, no personal data)",
|
|
289
|
-
previewIntro: " Example of the exact payload that would be sent when you run `kj run` (inspect more with `kj telemetry preview`):",
|
|
290
|
-
enabled: " -> Telemetry: enabled (thank you!)",
|
|
291
|
-
disabled: " -> Telemetry: disabled",
|
|
292
|
-
},
|
|
288
|
+
// English to stay consistent with the rest of the wizard (KJC-BUG-0088); the
|
|
289
|
+
// user's chosen pipeline/HU language drives `kj run` output, not this prompt.
|
|
290
|
+
const t = {
|
|
291
|
+
question: "Help improve Karajan by sending anonymous telemetry to the project?",
|
|
292
|
+
detail: " (version, OS, commands, pipeline duration — no code, no tasks, no personal data)",
|
|
293
|
+
previewIntro: " Example of the exact payload sent when you run `kj run` (inspect more with `kj telemetry preview`):",
|
|
294
|
+
enabled: " -> Telemetry: enabled (thank you!)",
|
|
295
|
+
disabled: " -> Telemetry: disabled",
|
|
293
296
|
};
|
|
294
|
-
const t = prompts[locale] || prompts.en;
|
|
295
297
|
logger.info(t.detail);
|
|
296
298
|
const example = buildTelemetryPayload("cli_command", { version: "x.y.z", command: "run" });
|
|
297
299
|
logger.info(t.previewIntro);
|
|
@@ -330,7 +332,7 @@ async function runWizard(config, logger) {
|
|
|
330
332
|
try {
|
|
331
333
|
const { coder, reviewer } = await askAgents(wizard, available, config, logger);
|
|
332
334
|
|
|
333
|
-
const enableTriage = await wizard.confirm("Enable triage (auto-
|
|
335
|
+
const enableTriage = await wizard.confirm("Enable triage (auto-pick a lighter pipeline for simple tasks)?", false);
|
|
334
336
|
config.pipeline.triage = config.pipeline.triage || {};
|
|
335
337
|
config.pipeline.triage.enabled = enableTriage;
|
|
336
338
|
logger.info(` -> Triage: ${enableTriage ? "enabled" : "disabled"}`);
|
|
@@ -339,7 +341,7 @@ async function runWizard(config, logger) {
|
|
|
339
341
|
config.sonarqube.enabled = enableSonar;
|
|
340
342
|
logger.info(` -> SonarQube: ${enableSonar ? "enabled" : "disabled"}`);
|
|
341
343
|
|
|
342
|
-
const enableHuBoard = await wizard.confirm("Enable HU Board
|
|
344
|
+
const enableHuBoard = await wizard.confirm("Enable the HU Board — a local web UI to track user stories (HUs)?", false);
|
|
343
345
|
config.hu_board = config.hu_board || {};
|
|
344
346
|
config.hu_board.enabled = enableHuBoard;
|
|
345
347
|
if (enableHuBoard) {
|
|
@@ -348,8 +350,19 @@ async function runWizard(config, logger) {
|
|
|
348
350
|
}
|
|
349
351
|
logger.info(` -> HU Board: ${enableHuBoard ? "enabled (auto-start on kj run)" : "disabled"}`);
|
|
350
352
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
+
// KJC-TSK-0571: per-role providers + fallback chains are advanced and
|
|
354
|
+
// collapse a newcomer's first run by ~12 questions. Ask one gate; default
|
|
355
|
+
// off → every role inherits from coder/reviewer.
|
|
356
|
+
const advancedRouting =
|
|
357
|
+
available.length > 1 &&
|
|
358
|
+
(await wizard.confirm(
|
|
359
|
+
"Configure advanced agent routing (a different AI per role + fallback chains)? Most users don't need this.",
|
|
360
|
+
false
|
|
361
|
+
));
|
|
362
|
+
await askPerRoleProviders(wizard, available, config, { coder, reviewer }, logger, advancedRouting);
|
|
363
|
+
if (advancedRouting) {
|
|
364
|
+
await askFallbackChain(wizard, available, config, { coder, reviewer }, logger);
|
|
365
|
+
}
|
|
353
366
|
await askMethodology(wizard, config, logger);
|
|
354
367
|
await askLanguages(wizard, config, logger);
|
|
355
368
|
|
|
@@ -686,16 +699,24 @@ export async function resolveConfigScope({ flags, interactive }) {
|
|
|
686
699
|
if (flags?.global) return { configPath: getConfigPath(), scope: "global" };
|
|
687
700
|
if (flags?.local) return { configPath: getProjectConfigPath(process.cwd()), scope: "local" };
|
|
688
701
|
if (interactive) {
|
|
702
|
+
const globalPath = getConfigPath();
|
|
703
|
+
// KJC-BUG-0087: on a true first run (no global config yet) there is no
|
|
704
|
+
// meaningful choice — set up the global config and skip a cold question a
|
|
705
|
+
// newcomer has no context to answer. Only when a global already exists is
|
|
706
|
+
// "override just for this project?" an actual decision worth asking.
|
|
707
|
+
if (!(await exists(globalPath))) {
|
|
708
|
+
return { configPath: globalPath, scope: "global" };
|
|
709
|
+
}
|
|
689
710
|
const wizard = createWizard();
|
|
690
711
|
try {
|
|
691
712
|
const choice = await wizard.select(
|
|
692
|
-
"
|
|
713
|
+
"You already have a global Karajan config. For this project, do you want to…",
|
|
693
714
|
[
|
|
694
|
-
{ value: "global", label:
|
|
695
|
-
{ value: "local",
|
|
715
|
+
{ value: "global", label: "Use the global config (your default for every project)" },
|
|
716
|
+
{ value: "local", label: `Override it just for this project (${getProjectConfigPath(process.cwd())})` },
|
|
696
717
|
]
|
|
697
718
|
);
|
|
698
|
-
return { configPath: choice === "local" ? getProjectConfigPath(process.cwd()) :
|
|
719
|
+
return { configPath: choice === "local" ? getProjectConfigPath(process.cwd()) : globalPath, scope: choice };
|
|
699
720
|
} finally {
|
|
700
721
|
wizard.close();
|
|
701
722
|
}
|
|
@@ -717,7 +738,11 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
717
738
|
const reviewRulesPath = path.resolve(karajanDir, "review-rules.md");
|
|
718
739
|
const coderRulesPath = path.resolve(karajanDir, "coder-rules.md");
|
|
719
740
|
|
|
720
|
-
const { config
|
|
741
|
+
const { config } = await loadConfig();
|
|
742
|
+
// KJC-BUG-0087: ask "reconfigure?" only when a config exists AT THE CHOSEN
|
|
743
|
+
// scope path — not when the global cascade has one. Picking local scope in a
|
|
744
|
+
// fresh dir must not report the unrelated global config as "already exists".
|
|
745
|
+
const configExists = await exists(configPath);
|
|
721
746
|
|
|
722
747
|
// Auto-detect language from OS locale for non-interactive mode
|
|
723
748
|
if (!interactive && !configExists) {
|
|
@@ -807,7 +832,7 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
807
832
|
await installAiTrashHook(logger);
|
|
808
833
|
} else {
|
|
809
834
|
logger.warn("ai-trash kj-trash binary not found — destructive ops unprotected.");
|
|
810
|
-
logger.warn("
|
|
835
|
+
logger.warn(" Install karajan-code globally (npm i -g karajan-code) so kj-trash is on PATH.");
|
|
811
836
|
}
|
|
812
837
|
}
|
|
813
838
|
|
|
@@ -864,6 +889,13 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
864
889
|
const version = JSON.parse(readFileSync(pkgPath, "utf8")).version;
|
|
865
890
|
sendTelemetryEvent("install", { version }, config).catch(() => {});
|
|
866
891
|
} catch { /* non-blocking */ }
|
|
892
|
+
|
|
893
|
+
// Clear close so a first-time user knows setup finished and what to do next,
|
|
894
|
+
// instead of being left at the end of a wall of detection logs (KJC-BUG-0088).
|
|
895
|
+
logger.info("");
|
|
896
|
+
logger.info("✓ Karajan is set up in this project.");
|
|
897
|
+
logger.info(' Next: kj run "describe what to build or fix"');
|
|
898
|
+
logger.info(" More: kj --help · kj doctor (check your setup)");
|
|
867
899
|
}
|
|
868
900
|
|
|
869
901
|
// Internal exports for unit testing (KJC-TSK-0367)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* advisory — per-artifact comparison engine for `kj harden` advisory mode
|
|
3
|
+
* (KJC-TSK-0565, épica KJC-PCS-0060). For each artifact harden manages it
|
|
4
|
+
* classifies the repo state: MISSING (no file nor equivalent), KJ_MANAGED (a
|
|
5
|
+
* kj:managed block, up to date or outdated vN) or USER_OWNED (the user's own,
|
|
6
|
+
* possibly in another format). Read-only; never writes or prints. The result
|
|
7
|
+
* feeds `--report` (Advisory B) / `--interactive` (Advisory C). Per-type
|
|
8
|
+
* improvement heuristics for USER_OWNED land in the follow-up slice (PR2).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { findManagedBlock } from "../utils/managed-markers.js";
|
|
15
|
+
import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
|
|
16
|
+
import { detectStackRoots } from "./stack-roots.js";
|
|
17
|
+
|
|
18
|
+
const BLOCK_VERSION = 1; // current version every markered config ships at
|
|
19
|
+
|
|
20
|
+
// Other filenames/formats for the same artifact, so kj never reports a false
|
|
21
|
+
// "missing". `pkgKey` is a package.json field that can hold inline config.
|
|
22
|
+
const EQUIVALENTS = {
|
|
23
|
+
commitlint: { files: [".commitlintrc", ".commitlintrc.js", ".commitlintrc.json", ".commitlintrc.yaml"], pkgKey: "commitlint" },
|
|
24
|
+
eslint: { files: ["eslint.config.mjs", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yaml"], pkgKey: "eslintConfig" },
|
|
25
|
+
prettier: { files: [".prettierrc", ".prettierrc.js", ".prettierrc.yaml", "prettier.config.js"], pkgKey: "prettier" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Concrete gains kj's standard would add to a USER_OWNED config, detected by
|
|
29
|
+
// substring probes (no AST parse): each probe absent from the user's content
|
|
30
|
+
// becomes one improvement. Heuristic by design — conservative, never a false
|
|
31
|
+
// "you're missing this" when the signal is clearly present.
|
|
32
|
+
const IMPROVEMENTS = {
|
|
33
|
+
eslint: [
|
|
34
|
+
{ needle: "no-var", id: "no-var", detail: "bans var (ES2025: const/let)" },
|
|
35
|
+
{ needle: "no-restricted-globals", id: "no-legacy-globals", detail: "bans alert/confirm/prompt, escape/unescape" },
|
|
36
|
+
{ needle: "no-restricted-properties", id: "no-legacy-apis", detail: "bans document.write, substr" },
|
|
37
|
+
],
|
|
38
|
+
commitlint: [
|
|
39
|
+
{ needle: "header-max-length", id: "header-max-length", detail: "caps the commit header at 100 chars" },
|
|
40
|
+
{ needle: "subject-case", id: "subject-case", detail: "enforces a lowercase subject" },
|
|
41
|
+
],
|
|
42
|
+
prettier: [{ needle: "printWidth", id: "print-width", detail: "pins printWidth (consistent wrapping)" }],
|
|
43
|
+
editorconfig: [
|
|
44
|
+
{ needle: "insert_final_newline", id: "final-newline", detail: "enforces a final newline" },
|
|
45
|
+
{ needle: "charset", id: "charset", detail: "pins charset = utf-8" },
|
|
46
|
+
{ needle: "trim_trailing_whitespace", id: "trim-trailing", detail: "trims trailing whitespace" },
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Improvements kj would add to the user's own config for `artifactId`. */
|
|
51
|
+
function improvementsFor(artifactId, content) {
|
|
52
|
+
return (IMPROVEMENTS[artifactId] ?? [])
|
|
53
|
+
.filter((probe) => !content.includes(probe.needle))
|
|
54
|
+
.map(({ id, detail }) => ({ id, detail }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build an artifact descriptor from a config-template entry. */
|
|
58
|
+
function toArtifact(cfg) {
|
|
59
|
+
const id = cfg.blockId ?? "prettier"; // the only marker-less JSON config is prettier
|
|
60
|
+
return {
|
|
61
|
+
id,
|
|
62
|
+
file: cfg.file,
|
|
63
|
+
blockId: cfg.blockId ?? null,
|
|
64
|
+
currentVersion: cfg.blockId ? BLOCK_VERSION : null,
|
|
65
|
+
equivalents: EQUIVALENTS[id]?.files ?? [],
|
|
66
|
+
pkgKey: EQUIVALENTS[id]?.pkgKey ?? null,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Locate the user's file for an artifact under `searchDir`; null if absent. */
|
|
71
|
+
function findArtifactFile(searchDir, artifact) {
|
|
72
|
+
for (const rel of [artifact.file, ...artifact.equivalents]) {
|
|
73
|
+
if (existsSync(join(searchDir, rel))) return rel;
|
|
74
|
+
}
|
|
75
|
+
if (artifact.pkgKey && existsSync(join(searchDir, "package.json"))) {
|
|
76
|
+
try {
|
|
77
|
+
if (JSON.parse(readFileSync(join(searchDir, "package.json"), "utf8"))[artifact.pkgKey] != null) {
|
|
78
|
+
return `package.json#${artifact.pkgKey}`;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* unreadable package.json → not a match */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Read the artifact body, or the JSON of an inline package.json key. */
|
|
88
|
+
function readArtifactContent(searchDir, foundAt) {
|
|
89
|
+
const [file, key] = foundAt.split("#");
|
|
90
|
+
const raw = readFileSync(join(searchDir, file), "utf8");
|
|
91
|
+
if (!key) return raw;
|
|
92
|
+
try {
|
|
93
|
+
return JSON.stringify(JSON.parse(raw)[key] ?? null);
|
|
94
|
+
} catch {
|
|
95
|
+
return raw;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Map a classified state to a recommendation + human rationale. */
|
|
100
|
+
function recommend(state) {
|
|
101
|
+
if (state.status === "MISSING") return { recommendation: "install", rationale: `kj would add ${state.file}`, mergeable: true };
|
|
102
|
+
if (state.status === "USER_OWNED") {
|
|
103
|
+
const n = state.improvements?.length ?? 0;
|
|
104
|
+
return n === 0
|
|
105
|
+
? { recommendation: "keep", rationale: "your own config — already covers the kj standard", mergeable: false }
|
|
106
|
+
: { recommendation: "review", rationale: `your own config — kj could add ${n} improvement(s)`, mergeable: true };
|
|
107
|
+
}
|
|
108
|
+
if (state.upToDate) return { recommendation: "keep", rationale: `managed by kj (v${state.managedVersion}), up to date`, mergeable: false };
|
|
109
|
+
return { recommendation: "update", rationale: `kj v${state.currentVersion} available (you have v${state.managedVersion})`, mergeable: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Classify a single artifact found (or not) under `searchDir`. */
|
|
113
|
+
export function classifyArtifact(searchDir, artifact) {
|
|
114
|
+
const foundAt = findArtifactFile(searchDir, artifact);
|
|
115
|
+
const base = { foundAt, managedVersion: null, upToDate: null, improvements: [] };
|
|
116
|
+
let state = { ...base, status: "MISSING" };
|
|
117
|
+
if (foundAt) {
|
|
118
|
+
const content = readArtifactContent(searchDir, foundAt);
|
|
119
|
+
if (artifact.blockId && content.includes(`kj:managed:${artifact.blockId}`)) {
|
|
120
|
+
const { version } = findManagedBlock(content, artifact.blockId);
|
|
121
|
+
state = { ...base, status: "KJ_MANAGED", managedVersion: version, upToDate: version != null && version >= artifact.currentVersion };
|
|
122
|
+
} else {
|
|
123
|
+
state = { ...base, status: "USER_OWNED", improvements: improvementsFor(artifact.id, content) };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const head = { id: artifact.id, file: artifact.file, currentVersion: artifact.currentVersion };
|
|
127
|
+
return { ...head, ...state, ...recommend({ ...state, ...head }) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Compare the whole repo against the kj standard, artifact by artifact:
|
|
132
|
+
* universal configs at the root, then each language's lint config inside its
|
|
133
|
+
* own root (monorepo-aware, mirroring installConfigsForRoots). `minimal`
|
|
134
|
+
* profile ships no config, so there is nothing to compare.
|
|
135
|
+
*/
|
|
136
|
+
export function compareHarden({ projectDir = process.cwd(), profile = "standard", only = [], exclude = [] } = {}) {
|
|
137
|
+
if (profile === "minimal") return { artifacts: [] };
|
|
138
|
+
const artifacts = UNIVERSAL_CONFIGS.map((cfg) => ({ dir: ".", ...classifyArtifact(projectDir, toArtifact(cfg)) }));
|
|
139
|
+
for (const { dir, language } of detectStackRoots(projectDir, { only, exclude })) {
|
|
140
|
+
const searchDir = dir === "." ? projectDir : join(projectDir, dir);
|
|
141
|
+
for (const cfg of CONFIGS_BY_LANGUAGE[language] ?? []) {
|
|
142
|
+
const res = classifyArtifact(searchDir, toArtifact(cfg));
|
|
143
|
+
artifacts.push({ dir, ...res, foundAt: res.foundAt && dir !== "." ? join(dir, res.foundAt) : res.foundAt });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { artifacts };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const MARK = { install: "+", update: "↑", review: "~", keep: "✓" };
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Render a compareHarden() result as human-readable lines (Advisory B). Pure:
|
|
153
|
+
* returns an array of strings, prints nothing. One line per artifact, indented
|
|
154
|
+
* bullets for each improvement, then a one-line tally.
|
|
155
|
+
*/
|
|
156
|
+
export function formatAdvisoryReport({ artifacts = [] } = {}) {
|
|
157
|
+
if (artifacts.length === 0) return ["kj harden — nothing to compare (minimal profile or empty repo)."];
|
|
158
|
+
const lines = ["kj harden — advisory report (read-only)", ""];
|
|
159
|
+
const tally = { install: 0, update: 0, review: 0, keep: 0 };
|
|
160
|
+
for (const a of artifacts) {
|
|
161
|
+
tally[a.recommendation] = (tally[a.recommendation] ?? 0) + 1;
|
|
162
|
+
const path = a.foundAt ?? (a.dir === "." ? a.file : join(a.dir, a.file));
|
|
163
|
+
lines.push(` ${MARK[a.recommendation] ?? "·"} ${a.recommendation.padEnd(7)} ${path} — ${a.rationale}`);
|
|
164
|
+
for (const imp of a.improvements ?? []) lines.push(` • ${imp.detail}`);
|
|
165
|
+
}
|
|
166
|
+
lines.push("", `${artifacts.length} artifact(s) · ${tally.install} missing · ${tally.review} to review · ${tally.update} outdated · ${tally.keep} ok`);
|
|
167
|
+
return lines;
|
|
168
|
+
}
|
|
@@ -103,10 +103,15 @@ export const JS_CONFIGS = [
|
|
|
103
103
|
export const PY_CONFIGS = [{ file: "ruff.toml", blockId: "ruff", style: "hash", body: RUFF_BODY }];
|
|
104
104
|
export const GO_CONFIGS = [{ file: ".golangci.yml", blockId: "golangci", style: "hash", body: GOLANGCI_BODY }];
|
|
105
105
|
|
|
106
|
+
// PHPStan static analysis — the PHP analogue of ruff/golangci.
|
|
107
|
+
export const PHPSTAN_BODY = ["parameters:", " level: 6", " paths:", " - src", " - tests"].join("\n");
|
|
108
|
+
export const PHP_CONFIGS = [{ file: "phpstan.neon", blockId: "phpstan", style: "hash", body: PHPSTAN_BODY }];
|
|
109
|
+
|
|
106
110
|
/** Lint/format configs by detected language. Unknown ⇒ universal only. */
|
|
107
111
|
export const CONFIGS_BY_LANGUAGE = {
|
|
108
112
|
javascript: JS_CONFIGS,
|
|
109
113
|
typescript: JS_CONFIGS,
|
|
110
114
|
python: PY_CONFIGS,
|
|
111
115
|
go: GO_CONFIGS,
|
|
116
|
+
php: PHP_CONFIGS,
|
|
112
117
|
};
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
export const COMMANDS_BY_LANGUAGE = {
|
|
13
13
|
python: { lint: "ruff check .", format: "ruff format --check .", test: "pytest" },
|
|
14
14
|
go: { lint: "go vet ./...", format: 'test -z "$(gofmt -l .)"', test: "go test ./..." },
|
|
15
|
+
php: {
|
|
16
|
+
lint: "vendor/bin/phpstan analyse --no-progress",
|
|
17
|
+
format: "vendor/bin/php-cs-fixer fix --dry-run --diff",
|
|
18
|
+
test: "vendor/bin/phpunit",
|
|
19
|
+
},
|
|
15
20
|
};
|
|
16
21
|
|
|
17
22
|
/** Native commands for a language, or {} when JS/TS or unknown. */
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* interactive — `kj harden --interactive` (KJC-TSK-0567, épica KJC-PCS-0060).
|
|
3
|
+
*
|
|
4
|
+
* Walks the advisory comparison (Advisory A/B) and asks, artifact by artifact,
|
|
5
|
+
* whether to apply the kj standard — with a safe default of leaving the user's
|
|
6
|
+
* own config untouched. Only the pieces the user says yes to are written:
|
|
7
|
+
*
|
|
8
|
+
* MISSING → add it? (default yes — same as seed-if-absent)
|
|
9
|
+
* KJ_MANAGED outdated → update it? (default yes)
|
|
10
|
+
* USER_OWNED w/ gaps → adopt kj's? (default NO — keep yours)
|
|
11
|
+
*
|
|
12
|
+
* The full merge (user's config + kj rules) is out of scope here (v2): adopt
|
|
13
|
+
* replaces the file with kj's standard. `ask` is injected so the flow is
|
|
14
|
+
* testable without a TTY; the CLI passes a readline-backed confirm.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
|
|
20
|
+
import { upsertManagedBlock } from "../utils/managed-markers.js";
|
|
21
|
+
import { compareHarden } from "./advisory.js";
|
|
22
|
+
import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
|
|
23
|
+
|
|
24
|
+
const BLOCK_VERSION = 1; // mirrors config-engine.js
|
|
25
|
+
|
|
26
|
+
// Artifact id (blockId, or "prettier" for the marker-less JSON) → its template.
|
|
27
|
+
const TEMPLATE_BY_ID = new Map();
|
|
28
|
+
for (const cfg of [...UNIVERSAL_CONFIGS, ...Object.values(CONFIGS_BY_LANGUAGE).flat()]) {
|
|
29
|
+
TEMPLATE_BY_ID.set(cfg.blockId ?? "prettier", cfg);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What to ask (and do) for one classified artifact; null = nothing to offer. */
|
|
33
|
+
export function decideAction(a) {
|
|
34
|
+
if (a.recommendation === "install") return { action: "add", defaultYes: true, prompt: `Add ${a.file}? (${a.rationale})` };
|
|
35
|
+
if (a.recommendation === "update") return { action: "update", defaultYes: true, prompt: `Update ${a.file} to kj v${a.currentVersion}?` };
|
|
36
|
+
if (a.recommendation === "review") {
|
|
37
|
+
return { action: "adopt", defaultYes: false, prompt: `Adopt kj's ${a.file}? Replaces yours, adds ${a.improvements.length} improvement(s). Default keeps yours.` };
|
|
38
|
+
}
|
|
39
|
+
return null; // keep / up-to-date — nothing to ask
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Write an artifact's kj body. `overwrite` discards the user's file (adopt). */
|
|
43
|
+
function applyArtifact(projectDir, dir, cfg, { overwrite }) {
|
|
44
|
+
const target = join(projectDir, dir === "." ? "" : dir, cfg.file);
|
|
45
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
46
|
+
if (cfg.json) {
|
|
47
|
+
writeFileSync(target, `${cfg.body}\n`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const source = !overwrite && existsSync(target) ? readFileSync(target, "utf8") : "";
|
|
51
|
+
const { content } = upsertManagedBlock({ source, blockId: cfg.blockId, version: BLOCK_VERSION, body: cfg.body, style: cfg.style });
|
|
52
|
+
writeFileSync(target, content);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run the interactive adoption flow. `ask(prompt, defaultYes) → Promise<bool>`.
|
|
57
|
+
* Returns the list of applied artifacts. Writes nothing the user declines.
|
|
58
|
+
*/
|
|
59
|
+
export async function interactiveHarden({ projectDir = process.cwd(), profile = "standard", only = [], exclude = [], ask, logger = console } = {}) {
|
|
60
|
+
const { artifacts } = compareHarden({ projectDir, profile, only, exclude });
|
|
61
|
+
const applied = [];
|
|
62
|
+
for (const a of artifacts) {
|
|
63
|
+
const plan = decideAction(a);
|
|
64
|
+
if (!plan) continue;
|
|
65
|
+
const yes = await ask(plan.prompt, plan.defaultYes);
|
|
66
|
+
if (!yes) continue;
|
|
67
|
+
const cfg = TEMPLATE_BY_ID.get(a.id);
|
|
68
|
+
if (!cfg) continue;
|
|
69
|
+
applyArtifact(projectDir, a.dir, cfg, { overwrite: plan.action === "adopt" });
|
|
70
|
+
const where = a.dir === "." ? a.file : join(a.dir, a.file);
|
|
71
|
+
applied.push({ file: where, action: plan.action });
|
|
72
|
+
logger.info?.(` ✓ ${plan.action} ${where}`);
|
|
73
|
+
}
|
|
74
|
+
if (applied.length === 0) logger.info?.("kj harden — nothing applied (kept everything as-is).");
|
|
75
|
+
return { ok: true, interactive: true, applied };
|
|
76
|
+
}
|
|
@@ -21,9 +21,38 @@ const IGNORE_DIRS = new Set([
|
|
|
21
21
|
"target",
|
|
22
22
|
"__pycache__",
|
|
23
23
|
"venv",
|
|
24
|
+
// Auxiliary code that isn't the app to harden (KJC-TSK-0564): demo/fixture
|
|
25
|
+
// dirs ship their own throwaway manifests and must not seed lint/CI config.
|
|
26
|
+
"examples",
|
|
27
|
+
"example",
|
|
28
|
+
"fixtures",
|
|
29
|
+
"samples",
|
|
30
|
+
"third_party",
|
|
31
|
+
"testdata",
|
|
32
|
+
"__fixtures__",
|
|
24
33
|
]);
|
|
25
34
|
const DEFAULT_MAX_DEPTH = 2;
|
|
26
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Minimal glob for harden scope flags — no dynamic RegExp (eslint security).
|
|
38
|
+
* Matches on exact equality, directory prefix (`wrappers` ⊇ `wrappers/python`)
|
|
39
|
+
* and `*` wildcards anchored at both ends unless the `*` is leading/trailing.
|
|
40
|
+
*/
|
|
41
|
+
export function matchesGlob(value, pattern) {
|
|
42
|
+
if (value === pattern || value.startsWith(`${pattern}/`)) return true;
|
|
43
|
+
if (!pattern.includes("*")) return false;
|
|
44
|
+
const pieces = pattern.split("*");
|
|
45
|
+
let idx = 0;
|
|
46
|
+
for (let i = 0; i < pieces.length; i += 1) {
|
|
47
|
+
const piece = pieces[i];
|
|
48
|
+
if (!piece) continue;
|
|
49
|
+
const at = value.indexOf(piece, idx);
|
|
50
|
+
if (at === -1 || (i === 0 && at !== 0)) return false;
|
|
51
|
+
idx = at + piece.length;
|
|
52
|
+
}
|
|
53
|
+
return pattern.endsWith("*") || value.length === idx;
|
|
54
|
+
}
|
|
55
|
+
|
|
27
56
|
/** Identify the language of a single directory by its manifest files. */
|
|
28
57
|
export function languageAt(dir) {
|
|
29
58
|
if (existsSync(join(dir, "package.json"))) {
|
|
@@ -39,10 +68,11 @@ export function languageAt(dir) {
|
|
|
39
68
|
}
|
|
40
69
|
if (["pyproject.toml", "requirements.txt", "setup.py"].some((f) => existsSync(join(dir, f)))) return "python";
|
|
41
70
|
if (existsSync(join(dir, "go.mod"))) return "go";
|
|
71
|
+
if (existsSync(join(dir, "composer.json"))) return "php";
|
|
42
72
|
return null;
|
|
43
73
|
}
|
|
44
74
|
|
|
45
|
-
export function detectStackRoots(projectDir, { maxDepth = DEFAULT_MAX_DEPTH } = {}) {
|
|
75
|
+
export function detectStackRoots(projectDir, { maxDepth = DEFAULT_MAX_DEPTH, only = [], exclude = [] } = {}) {
|
|
46
76
|
const found = [];
|
|
47
77
|
function walk(dir, depth) {
|
|
48
78
|
const language = languageAt(dir);
|
|
@@ -68,7 +98,8 @@ export function detectStackRoots(projectDir, { maxDepth = DEFAULT_MAX_DEPTH } =
|
|
|
68
98
|
const cur = byLanguage.get(f.language);
|
|
69
99
|
if (!cur || f.depth < cur.depth) byLanguage.set(f.language, f);
|
|
70
100
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
101
|
+
let roots = [...byLanguage.values()].map(({ dir, language }) => ({ dir, language }));
|
|
102
|
+
if (only.length) roots = roots.filter((r) => only.some((o) => matchesGlob(r.dir, o)));
|
|
103
|
+
if (exclude.length) roots = roots.filter((r) => !exclude.some((g) => matchesGlob(r.dir, g)));
|
|
104
|
+
return roots.sort((a, b) => a.dir.localeCompare(b.dir));
|
|
74
105
|
}
|
|
@@ -78,6 +78,14 @@ const QUALITY_BY_LANGUAGE = {
|
|
|
78
78
|
" - run: go vet ./...",
|
|
79
79
|
" - run: go test ./...",
|
|
80
80
|
]),
|
|
81
|
+
php: header([
|
|
82
|
+
" - uses: shivammathur/setup-php@v2",
|
|
83
|
+
" with:",
|
|
84
|
+
" php-version: '8.3'",
|
|
85
|
+
" - run: composer install --no-interaction --no-progress",
|
|
86
|
+
" - run: vendor/bin/phpstan analyse --no-progress",
|
|
87
|
+
" - run: vendor/bin/phpunit",
|
|
88
|
+
]),
|
|
81
89
|
};
|
|
82
90
|
QUALITY_BY_LANGUAGE.typescript = QUALITY_BY_LANGUAGE.javascript;
|
|
83
91
|
|
|
@@ -8,7 +8,10 @@ export async function detectSqueezr() {
|
|
|
8
8
|
try {
|
|
9
9
|
const result = await runCommand("squeezr", ["--version"]);
|
|
10
10
|
if (result.exitCode === 0) {
|
|
11
|
-
|
|
11
|
+
// `squeezr --version` may append an "update available" banner box; keep
|
|
12
|
+
// just the semver so callers never render that noise (KJC-BUG-0088).
|
|
13
|
+
const raw = (result.stdout || "").trim();
|
|
14
|
+
const version = raw.match(/\d+\.\d+\.\d+[\w.-]*/)?.[0] || raw.split("\n")[0].trim() || null;
|
|
12
15
|
return { available: true, version };
|
|
13
16
|
}
|
|
14
17
|
return { available: false, version: null };
|