mikoshi-construct 0.2.0 → 0.3.1
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 +16 -1
- package/dist/cli.js +1450 -712
- package/package.json +16 -11
- package/templates/ai/claude/_claude/commands/plan.md +2 -0
- package/templates/ai/claude/_claude/skills/implement/SKILL.md +20 -6
- package/templates/ai/claude/scripts/construct/implement.workflow.mjs +53 -24
- package/templates/base/architecture/decisions/README.md +26 -0
- package/templates/base/architecture/principles.md +3 -1
- /package/templates/presets/monorepo/{baseline → sample}/scripts/tests/lint/syntax-policy.test.ts.eta +0 -0
- /package/templates/presets/node-backend/{baseline → sample}/scripts/tests/lint/syntax-policy.test.ts +0 -0
- /package/templates/presets/node-frontend/{baseline → sample}/scripts/tests/lint/syntax-policy.test.ts +0 -0
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import
|
|
4
|
+
import path22 from "path";
|
|
5
5
|
import process6 from "process";
|
|
6
6
|
import { isTTY } from "@clack/prompts";
|
|
7
7
|
import { defineCommand, runMain } from "citty";
|
|
@@ -258,10 +258,50 @@ import process from "process";
|
|
|
258
258
|
|
|
259
259
|
// src/manifest.ts
|
|
260
260
|
import { createHash } from "crypto";
|
|
261
|
-
import { existsSync as
|
|
261
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync } from "fs";
|
|
262
|
+
import path4 from "path";
|
|
263
|
+
|
|
264
|
+
// src/detect/existing.ts
|
|
265
|
+
import { existsSync as existsSync3, readdirSync as readdirSync2 } from "fs";
|
|
262
266
|
import path3 from "path";
|
|
267
|
+
var ESLINT_CONFIGS = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yml"];
|
|
268
|
+
var DEFAULT_COMPOSITION_DIR = "architecture/composition";
|
|
269
|
+
var COMPOSITION_CANDIDATES = [DEFAULT_COMPOSITION_DIR, "docs/architecture/composition", "docs/composition", "composition"];
|
|
270
|
+
var OPENAPI_CANDIDATES = ["contracts/api/openapi.yaml", "contracts/api/openapi.yml", "contracts/openapi.yaml", "openapi.yaml", "openapi.yml", "openapi.json", "api/openapi.yaml", "docs/openapi.yaml"];
|
|
271
|
+
function anyExists(dir, candidates) {
|
|
272
|
+
return candidates.some((candidate) => existsSync3(path3.join(dir, candidate)));
|
|
273
|
+
}
|
|
274
|
+
function firstExisting(dir, candidates) {
|
|
275
|
+
return candidates.find((candidate) => existsSync3(path3.join(dir, candidate))) ?? null;
|
|
276
|
+
}
|
|
277
|
+
function compositionDir(dir) {
|
|
278
|
+
return COMPOSITION_CANDIDATES.find((candidate) => {
|
|
279
|
+
const absolute = path3.join(dir, candidate);
|
|
280
|
+
return existsSync3(absolute) && readdirSync2(absolute).some((file) => file.endsWith(".yaml") || file.endsWith(".yml"));
|
|
281
|
+
}) ?? null;
|
|
282
|
+
}
|
|
283
|
+
function hasWorkflows(dir) {
|
|
284
|
+
const workflows = path3.join(dir, ".github", "workflows");
|
|
285
|
+
return existsSync3(workflows) && readdirSync2(workflows).some((file) => file.endsWith(".yml") || file.endsWith(".yaml"));
|
|
286
|
+
}
|
|
287
|
+
function detectExisting(dir) {
|
|
288
|
+
return {
|
|
289
|
+
packageJson: existsSync3(path3.join(dir, "package.json")),
|
|
290
|
+
tsconfig: existsSync3(path3.join(dir, "tsconfig.json")),
|
|
291
|
+
eslintConfig: anyExists(dir, ESLINT_CONFIGS),
|
|
292
|
+
githubWorkflows: hasWorkflows(dir),
|
|
293
|
+
claudeMd: existsSync3(path3.join(dir, "CLAUDE.md")),
|
|
294
|
+
agentsMd: existsSync3(path3.join(dir, "AGENTS.md")),
|
|
295
|
+
cursorRules: existsSync3(path3.join(dir, ".cursor", "rules")),
|
|
296
|
+
openapi: firstExisting(dir, OPENAPI_CANDIDATES),
|
|
297
|
+
compositionDir: compositionDir(dir),
|
|
298
|
+
constructJson: existsSync3(path3.join(dir, "construct.json"))
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/manifest.ts
|
|
263
303
|
var MANIFEST_FILE = "construct.json";
|
|
264
|
-
var MANIFEST_VERSION =
|
|
304
|
+
var MANIFEST_VERSION = 4;
|
|
265
305
|
var DISCOVERY_MARKERS = [
|
|
266
306
|
"product",
|
|
267
307
|
"module-map",
|
|
@@ -277,7 +317,7 @@ var DISCOVERY_MARKERS = [
|
|
|
277
317
|
function sha256(content) {
|
|
278
318
|
return createHash("sha256").update(content).digest("hex");
|
|
279
319
|
}
|
|
280
|
-
function markerFile(marker, compositionDir2
|
|
320
|
+
function markerFile(marker, compositionDir2) {
|
|
281
321
|
switch (marker) {
|
|
282
322
|
case "composition":
|
|
283
323
|
return compositionDir2;
|
|
@@ -288,9 +328,10 @@ function markerFile(marker, compositionDir2 = "architecture/composition") {
|
|
|
288
328
|
}
|
|
289
329
|
}
|
|
290
330
|
function buildManifest(input) {
|
|
291
|
-
const
|
|
331
|
+
const written = {};
|
|
292
332
|
for (const op of input.written)
|
|
293
|
-
|
|
333
|
+
written[op.target] = sha256(op.content);
|
|
334
|
+
const files = { ...input.previous?.files, ...written };
|
|
294
335
|
const markers = Object.fromEntries(DISCOVERY_MARKERS.map((marker) => [marker, {
|
|
295
336
|
file: markerFile(marker, input.vars.compositionDir),
|
|
296
337
|
authoredBy: "unknown",
|
|
@@ -298,8 +339,8 @@ function buildManifest(input) {
|
|
|
298
339
|
}]));
|
|
299
340
|
return {
|
|
300
341
|
manifestVersion: MANIFEST_VERSION,
|
|
301
|
-
construct: input.version,
|
|
302
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
342
|
+
construct: input.previous?.construct ?? input.version,
|
|
343
|
+
createdAt: input.previous?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
303
344
|
preset: input.preset,
|
|
304
345
|
ai: input.ai,
|
|
305
346
|
review: input.review === "none" ? null : { provider: input.review, model: input.vars.reviewModel },
|
|
@@ -308,9 +349,21 @@ function buildManifest(input) {
|
|
|
308
349
|
contracts: input.contracts ? { path: input.vars.contractPath, types: input.vars.contractTypesOutput } : null,
|
|
309
350
|
vars: input.vars,
|
|
310
351
|
files,
|
|
311
|
-
|
|
352
|
+
variants: { ...input.previous?.variants, ...variantsOf(input.written) },
|
|
353
|
+
discovery: input.previous?.discovery ?? { baseSha: null, filledAt: null, markers },
|
|
354
|
+
sync: input.previous?.sync ?? null
|
|
312
355
|
};
|
|
313
356
|
}
|
|
357
|
+
function variantsOf(written) {
|
|
358
|
+
return Object.fromEntries(written.flatMap((op) => op.variant == null ? [] : [[op.target, op.variant]]));
|
|
359
|
+
}
|
|
360
|
+
function isTemplateVariant(value) {
|
|
361
|
+
return value === "default" || value === "existing";
|
|
362
|
+
}
|
|
363
|
+
function upgradeVariants(raw) {
|
|
364
|
+
const value = raw ?? {};
|
|
365
|
+
return Object.fromEntries(Object.entries(value).flatMap(([target, variant]) => isTemplateVariant(variant) ? [[target, variant]] : []));
|
|
366
|
+
}
|
|
314
367
|
function upgradeMarker(recorded, file) {
|
|
315
368
|
if (typeof recorded === "string")
|
|
316
369
|
return { file: recorded, authoredBy: "unknown", sha: null };
|
|
@@ -321,31 +374,63 @@ function upgradeMarker(recorded, file) {
|
|
|
321
374
|
sha: typeof value.sha === "string" ? value.sha : null
|
|
322
375
|
};
|
|
323
376
|
}
|
|
377
|
+
function upgradeSync(raw) {
|
|
378
|
+
const value = raw ?? {};
|
|
379
|
+
if (typeof value.ranAt !== "string" || typeof value.fromVersion !== "string" || typeof value.toVersion !== "string")
|
|
380
|
+
return null;
|
|
381
|
+
return {
|
|
382
|
+
ranAt: value.ranAt,
|
|
383
|
+
fromVersion: value.fromVersion,
|
|
384
|
+
toVersion: value.toVersion,
|
|
385
|
+
files: typeof value.files === "object" && value.files != null ? { ...value.files } : {},
|
|
386
|
+
variants: upgradeVariants(value.variants)
|
|
387
|
+
};
|
|
388
|
+
}
|
|
324
389
|
function upgradeManifest(raw) {
|
|
325
390
|
const manifest = raw;
|
|
326
391
|
const discovery = manifest.discovery ?? {};
|
|
327
392
|
const recorded = discovery.markers ?? discovery;
|
|
328
393
|
const markers = Object.fromEntries(DISCOVERY_MARKERS.map((marker) => [
|
|
329
394
|
marker,
|
|
330
|
-
upgradeMarker(recorded[marker], markerFile(marker, manifest.vars?.compositionDir))
|
|
395
|
+
upgradeMarker(recorded[marker], markerFile(marker, manifest.vars?.compositionDir ?? DEFAULT_COMPOSITION_DIR))
|
|
331
396
|
]));
|
|
332
397
|
return {
|
|
333
398
|
...manifest,
|
|
334
399
|
manifestVersion: MANIFEST_VERSION,
|
|
400
|
+
variants: upgradeVariants(manifest.variants),
|
|
335
401
|
discovery: {
|
|
336
402
|
baseSha: typeof discovery.baseSha === "string" ? discovery.baseSha : null,
|
|
337
403
|
filledAt: typeof discovery.filledAt === "string" ? discovery.filledAt : null,
|
|
338
404
|
markers
|
|
405
|
+
},
|
|
406
|
+
sync: upgradeSync(manifest.sync)
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function recordSync(manifest, run) {
|
|
410
|
+
return {
|
|
411
|
+
...manifest,
|
|
412
|
+
sync: {
|
|
413
|
+
ranAt: run.ranAt,
|
|
414
|
+
fromVersion: manifest.construct,
|
|
415
|
+
toVersion: run.toVersion,
|
|
416
|
+
files: { ...manifest.sync?.files, ...run.files },
|
|
417
|
+
variants: { ...manifest.sync?.variants, ...run.variants }
|
|
339
418
|
}
|
|
340
419
|
};
|
|
341
420
|
}
|
|
421
|
+
function recordedShas(manifest) {
|
|
422
|
+
return { ...manifest.files, ...manifest.sync?.files };
|
|
423
|
+
}
|
|
424
|
+
function recordedVariants(manifest) {
|
|
425
|
+
return { ...manifest.variants, ...manifest.sync?.variants };
|
|
426
|
+
}
|
|
342
427
|
function writeManifest(root, manifest) {
|
|
343
|
-
writeFileSync(
|
|
428
|
+
writeFileSync(path4.join(root, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
|
|
344
429
|
`);
|
|
345
430
|
}
|
|
346
431
|
function readManifest(root) {
|
|
347
|
-
const file =
|
|
348
|
-
if (!
|
|
432
|
+
const file = path4.join(root, MANIFEST_FILE);
|
|
433
|
+
if (!existsSync4(file))
|
|
349
434
|
return null;
|
|
350
435
|
return upgradeManifest(JSON.parse(readFileSync3(file, "utf8")));
|
|
351
436
|
}
|
|
@@ -466,17 +551,35 @@ function costReport(cwd, options = {}) {
|
|
|
466
551
|
};
|
|
467
552
|
}
|
|
468
553
|
|
|
554
|
+
// src/version.ts
|
|
555
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
556
|
+
import path5 from "path";
|
|
557
|
+
import { fileURLToPath } from "url";
|
|
558
|
+
var HERE = path5.dirname(fileURLToPath(import.meta.url));
|
|
559
|
+
function readVersion() {
|
|
560
|
+
for (const candidate of ["../package.json", "../../package.json"]) {
|
|
561
|
+
try {
|
|
562
|
+
const parsed = JSON.parse(readFileSync4(path5.resolve(HERE, candidate), "utf8"));
|
|
563
|
+
if (parsed.name === "mikoshi-construct" && parsed.version != null)
|
|
564
|
+
return parsed.version;
|
|
565
|
+
} catch {
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
return "0.0.0";
|
|
569
|
+
}
|
|
570
|
+
var VERSION = readVersion();
|
|
571
|
+
|
|
469
572
|
// src/commands/doctor/baseline.ts
|
|
470
|
-
import { existsSync as
|
|
471
|
-
import
|
|
573
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
|
|
574
|
+
import path6 from "path";
|
|
472
575
|
function baselineVerdict(root, manifest) {
|
|
473
576
|
const missingFiles = [];
|
|
474
577
|
const modifiedFiles = [];
|
|
475
578
|
for (const [file, hash] of Object.entries(manifest.files)) {
|
|
476
|
-
const absolute =
|
|
477
|
-
if (!
|
|
579
|
+
const absolute = path6.join(root, file);
|
|
580
|
+
if (!existsSync5(absolute))
|
|
478
581
|
missingFiles.push(file);
|
|
479
|
-
else if (sha256(
|
|
582
|
+
else if (sha256(readFileSync5(absolute, "utf8")) !== hash)
|
|
480
583
|
modifiedFiles.push(file);
|
|
481
584
|
}
|
|
482
585
|
return { missingFiles, modifiedFiles };
|
|
@@ -512,8 +615,8 @@ function ciCheck(evidence) {
|
|
|
512
615
|
}
|
|
513
616
|
|
|
514
617
|
// src/commands/doctor/runner.ts
|
|
515
|
-
import { existsSync as
|
|
516
|
-
import
|
|
618
|
+
import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
|
|
619
|
+
import path7 from "path";
|
|
517
620
|
var RUNNER_CONFIG_FILES = [
|
|
518
621
|
"vitest.config.ts",
|
|
519
622
|
"vitest.config.mts",
|
|
@@ -601,7 +704,7 @@ function matchesAnyGlob(file, globs) {
|
|
|
601
704
|
}
|
|
602
705
|
function readRunnerFacts(root, harnessText) {
|
|
603
706
|
const invokedByHarness = harnessText.includes("vitest");
|
|
604
|
-
const file = RUNNER_CONFIG_FILES.find((candidate) =>
|
|
707
|
+
const file = RUNNER_CONFIG_FILES.find((candidate) => existsSync6(path7.join(root, candidate))) ?? null;
|
|
605
708
|
if (file == null) {
|
|
606
709
|
return {
|
|
607
710
|
file: null,
|
|
@@ -612,7 +715,7 @@ function readRunnerFacts(root, harnessText) {
|
|
|
612
715
|
}
|
|
613
716
|
let source;
|
|
614
717
|
try {
|
|
615
|
-
source =
|
|
718
|
+
source = readFileSync6(path7.join(root, file), "utf8");
|
|
616
719
|
} catch {
|
|
617
720
|
return { file, globs: null, note: `${file} cannot be read, so the include list is unknown`, invokedByHarness };
|
|
618
721
|
}
|
|
@@ -678,8 +781,16 @@ function redGateCheck(evidence) {
|
|
|
678
781
|
}
|
|
679
782
|
|
|
680
783
|
// src/commands/doctor/discovery.ts
|
|
681
|
-
import { existsSync as
|
|
682
|
-
import
|
|
784
|
+
import { existsSync as existsSync7, readdirSync as readdirSync3, readFileSync as readFileSync7 } from "fs";
|
|
785
|
+
import path8 from "path";
|
|
786
|
+
|
|
787
|
+
// src/detect/facts.ts
|
|
788
|
+
function factsTheRepositoryEstablishes(root) {
|
|
789
|
+
const existing = detectExisting(root);
|
|
790
|
+
return existing.compositionDir == null ? {} : { compositionDir: existing.compositionDir };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// src/commands/doctor/discovery.ts
|
|
683
794
|
var DISCOVERY_PLACEHOLDER = "_Not discovered yet \u2014 run `/construct-discover`._";
|
|
684
795
|
function markerOpen(marker) {
|
|
685
796
|
return `<!-- construct:discover:${marker} -->`;
|
|
@@ -696,33 +807,42 @@ function blockBody(document, marker) {
|
|
|
696
807
|
return body === "" || body === DISCOVERY_PLACEHOLDER ? null : body;
|
|
697
808
|
}
|
|
698
809
|
function compositionBody(directory) {
|
|
699
|
-
if (!
|
|
810
|
+
if (!existsSync7(directory))
|
|
700
811
|
return null;
|
|
701
|
-
const models =
|
|
812
|
+
const models = readdirSync3(directory).filter((file) => file.endsWith(".yaml")).sort();
|
|
702
813
|
if (models.length === 0)
|
|
703
814
|
return null;
|
|
704
815
|
return models.map((model) => `${model}
|
|
705
|
-
${
|
|
816
|
+
${readFileSync7(path8.join(directory, model), "utf8")}`).join("\n");
|
|
706
817
|
}
|
|
707
818
|
function markerBody(root, marker, file) {
|
|
708
|
-
const location =
|
|
819
|
+
const location = path8.join(root, file);
|
|
709
820
|
if (marker === "composition")
|
|
710
821
|
return compositionBody(location);
|
|
711
|
-
if (!
|
|
822
|
+
if (!existsSync7(location))
|
|
712
823
|
return null;
|
|
713
|
-
return blockBody(
|
|
824
|
+
return blockBody(readFileSync7(location, "utf8"), marker);
|
|
825
|
+
}
|
|
826
|
+
function markerFileFor(root, manifest, marker) {
|
|
827
|
+
const recordedFile = manifest.discovery.markers[marker].file;
|
|
828
|
+
if (marker !== "composition")
|
|
829
|
+
return recordedFile;
|
|
830
|
+
const decided = manifest.vars?.compositionDir;
|
|
831
|
+
if (decided != null && decided !== "")
|
|
832
|
+
return decided;
|
|
833
|
+
return factsTheRepositoryEstablishes(root).compositionDir ?? recordedFile;
|
|
714
834
|
}
|
|
715
835
|
function missingDiscovery(root, manifest) {
|
|
716
|
-
return DISCOVERY_MARKERS.filter((marker) => markerBody(root, marker, manifest
|
|
836
|
+
return DISCOVERY_MARKERS.filter((marker) => markerBody(root, marker, markerFileFor(root, manifest, marker)) == null);
|
|
717
837
|
}
|
|
718
838
|
|
|
719
839
|
// src/commands/doctor/evidence.ts
|
|
720
|
-
import { readFileSync as
|
|
721
|
-
import
|
|
840
|
+
import { readFileSync as readFileSync11 } from "fs";
|
|
841
|
+
import path12 from "path";
|
|
722
842
|
|
|
723
843
|
// src/commands/doctor/harness.ts
|
|
724
|
-
import { existsSync as
|
|
725
|
-
import
|
|
844
|
+
import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
|
|
845
|
+
import path9 from "path";
|
|
726
846
|
var REQUIRED_QUALITY_STEPS = ["lint", "typecheck", "test"];
|
|
727
847
|
var SCRIPT_REFERENCE = /(?:^|&&|\|\||;)\s*(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?([\w:.-]+)/g;
|
|
728
848
|
function harnessScriptName(command) {
|
|
@@ -740,8 +860,8 @@ function expandScript(scripts, name, seen) {
|
|
|
740
860
|
}
|
|
741
861
|
function readHarnessFacts(root, command) {
|
|
742
862
|
const script = harnessScriptName(command);
|
|
743
|
-
const manifestPath =
|
|
744
|
-
const packageJson =
|
|
863
|
+
const manifestPath = path9.join(root, "package.json");
|
|
864
|
+
const packageJson = existsSync8(manifestPath) ? JSON.parse(readFileSync8(manifestPath, "utf8")) : null;
|
|
745
865
|
const scripts = packageJson?.scripts ?? {};
|
|
746
866
|
const body = scripts[script] ?? null;
|
|
747
867
|
return {
|
|
@@ -760,7 +880,7 @@ function runsHarnessCommand(text2, forms) {
|
|
|
760
880
|
function contractProblems(root, contracts, script, body) {
|
|
761
881
|
if (contracts == null)
|
|
762
882
|
return [];
|
|
763
|
-
const problems = [contracts.path, contracts.types].filter((file) => !
|
|
883
|
+
const problems = [contracts.path, contracts.types].filter((file) => !existsSync8(path9.join(root, file))).map((file) => `${file} is missing (construct.json \u2192 contracts)`);
|
|
764
884
|
if (!body.includes("contracts:check"))
|
|
765
885
|
problems.push(`"${script}" does not run contracts:check`);
|
|
766
886
|
return problems;
|
|
@@ -778,8 +898,8 @@ function harnessProblems(root, manifest, facts) {
|
|
|
778
898
|
}
|
|
779
899
|
|
|
780
900
|
// src/commands/doctor/hooks.ts
|
|
781
|
-
import { existsSync as
|
|
782
|
-
import
|
|
901
|
+
import { existsSync as existsSync9, readdirSync as readdirSync4, readFileSync as readFileSync9, statSync as statSync2 } from "fs";
|
|
902
|
+
import path10 from "path";
|
|
783
903
|
var LEFTHOOK_FILES = ["lefthook.yml", "lefthook.yaml", "lefthook.toml", "lefthook.json", ".lefthook.yml", ".lefthook.yaml"];
|
|
784
904
|
var SIMPLE_GIT_HOOKS_FILES = [".simple-git-hooks.js", ".simple-git-hooks.cjs", ".simple-git-hooks.mjs", ".simple-git-hooks.json", "simple-git-hooks.json"];
|
|
785
905
|
var HUSKY_DIR = ".husky";
|
|
@@ -787,20 +907,20 @@ var GIT_CONFIG = ".git/config";
|
|
|
787
907
|
var HOOK_SCRIPTS = ["precommit", "pre-commit", "prepush", "pre-push"];
|
|
788
908
|
function read(root, file) {
|
|
789
909
|
try {
|
|
790
|
-
return
|
|
910
|
+
return readFileSync9(path10.join(root, file), "utf8");
|
|
791
911
|
} catch {
|
|
792
912
|
return null;
|
|
793
913
|
}
|
|
794
914
|
}
|
|
795
915
|
function huskyHooks(root) {
|
|
796
|
-
const directory =
|
|
797
|
-
if (!
|
|
916
|
+
const directory = path10.join(root, HUSKY_DIR);
|
|
917
|
+
if (!existsSync9(directory) || !statSync2(directory).isDirectory())
|
|
798
918
|
return [];
|
|
799
|
-
return
|
|
919
|
+
return readdirSync4(directory).filter((entry) => !entry.startsWith("_") && !entry.startsWith(".")).sort().map((entry) => `${HUSKY_DIR}/${entry}`);
|
|
800
920
|
}
|
|
801
921
|
function managerFiles(root, packageJson) {
|
|
802
922
|
const files = [...huskyHooks(root)];
|
|
803
|
-
files.push(...[...LEFTHOOK_FILES, ...SIMPLE_GIT_HOOKS_FILES].filter((file) =>
|
|
923
|
+
files.push(...[...LEFTHOOK_FILES, ...SIMPLE_GIT_HOOKS_FILES].filter((file) => existsSync9(path10.join(root, file))));
|
|
804
924
|
if (packageJson != null && "simple-git-hooks" in packageJson)
|
|
805
925
|
files.push("package.json (simple-git-hooks)");
|
|
806
926
|
const gitConfig = read(root, GIT_CONFIG);
|
|
@@ -823,8 +943,8 @@ function readHookFacts(root, packageJson, scripts, commandForms) {
|
|
|
823
943
|
}
|
|
824
944
|
|
|
825
945
|
// src/commands/doctor/workflows.ts
|
|
826
|
-
import { existsSync as
|
|
827
|
-
import
|
|
946
|
+
import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync as readFileSync10 } from "fs";
|
|
947
|
+
import path11 from "path";
|
|
828
948
|
var WORKFLOWS_DIR = ".github/workflows";
|
|
829
949
|
var RUN_STEP = /(?:^|\s)run:[ \t]*(\S.*)$/;
|
|
830
950
|
function runStepTexts(source) {
|
|
@@ -851,16 +971,16 @@ function runStepTexts(source) {
|
|
|
851
971
|
return steps;
|
|
852
972
|
}
|
|
853
973
|
function readWorkflowFacts(root, commandForms) {
|
|
854
|
-
const directory =
|
|
855
|
-
if (!
|
|
974
|
+
const directory = path11.join(root, WORKFLOWS_DIR);
|
|
975
|
+
if (!existsSync10(directory))
|
|
856
976
|
return { directory: WORKFLOWS_DIR, files: [], harnessWorkflow: null, unreadable: [] };
|
|
857
|
-
const files =
|
|
977
|
+
const files = readdirSync5(directory).filter((file) => file.endsWith(".yml") || file.endsWith(".yaml")).sort();
|
|
858
978
|
const unreadable = [];
|
|
859
979
|
let harnessWorkflow = null;
|
|
860
980
|
for (const file of files) {
|
|
861
981
|
let source;
|
|
862
982
|
try {
|
|
863
|
-
source =
|
|
983
|
+
source = readFileSync10(path11.join(directory, file), "utf8");
|
|
864
984
|
} catch {
|
|
865
985
|
unreadable.push(`${WORKFLOWS_DIR}/${file}`);
|
|
866
986
|
continue;
|
|
@@ -887,7 +1007,7 @@ function gatherEvidence(root, manifest) {
|
|
|
887
1007
|
const unreadable = [...workflows.unreadable];
|
|
888
1008
|
for (const file of recordedTests) {
|
|
889
1009
|
try {
|
|
890
|
-
const source =
|
|
1010
|
+
const source = readFileSync11(path12.join(root, file), "utf8");
|
|
891
1011
|
if (LINT_POLICY_MARKERS.some((marker) => source.includes(marker)))
|
|
892
1012
|
policyTests.push(file);
|
|
893
1013
|
} catch {
|
|
@@ -909,7 +1029,7 @@ function discoveryProvenance(root, manifest) {
|
|
|
909
1029
|
return {
|
|
910
1030
|
marker,
|
|
911
1031
|
file: recorded.file,
|
|
912
|
-
authorship: markerAuthorship(recorded, markerBody(root, marker,
|
|
1032
|
+
authorship: markerAuthorship(recorded, markerBody(root, marker, markerFileFor(root, manifest, marker)))
|
|
913
1033
|
};
|
|
914
1034
|
});
|
|
915
1035
|
}
|
|
@@ -943,541 +1063,256 @@ function weakestLink(checks) {
|
|
|
943
1063
|
return { id: weakest.id, level: weakest.level };
|
|
944
1064
|
}
|
|
945
1065
|
|
|
946
|
-
// src/
|
|
947
|
-
|
|
948
|
-
|
|
1066
|
+
// src/sync/replay.ts
|
|
1067
|
+
import { existsSync as existsSync13, readFileSync as readFileSync13 } from "fs";
|
|
1068
|
+
import { tmpdir } from "os";
|
|
1069
|
+
import path15 from "path";
|
|
1070
|
+
|
|
1071
|
+
// src/materialize/plan.ts
|
|
1072
|
+
import { existsSync as existsSync12, readFileSync as readFileSync12 } from "fs";
|
|
1073
|
+
import path14 from "path";
|
|
1074
|
+
|
|
1075
|
+
// src/materialize/rules.ts
|
|
1076
|
+
var CLAUDE_RULES_DIR = ".claude/rules/";
|
|
1077
|
+
var CURSOR_RULES_DIR = ".cursor/rules/";
|
|
1078
|
+
var DISCOVERY_PROTOCOL = ".claude/commands/construct-discover.md";
|
|
1079
|
+
var CURSOR_DISCOVERY_RULE = ".cursor/rules/construct-discover.mdc";
|
|
1080
|
+
var FRONTMATTER = /^---\n([\s\S]*?)\n---\n/;
|
|
1081
|
+
function unquote(value) {
|
|
1082
|
+
return value.trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
949
1083
|
}
|
|
950
|
-
function
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1084
|
+
function parseFrontmatter(block) {
|
|
1085
|
+
const paths = [];
|
|
1086
|
+
let description;
|
|
1087
|
+
let inPaths = false;
|
|
1088
|
+
for (const line of block.split("\n")) {
|
|
1089
|
+
const item = /^\s*-\s+(\S.*)$/.exec(line);
|
|
1090
|
+
if (inPaths && item != null) {
|
|
1091
|
+
paths.push(unquote(item[1]));
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
inPaths = /^paths:\s*$/.test(line);
|
|
1095
|
+
const scalar = /^description:\s*(\S.*)$/.exec(line);
|
|
1096
|
+
if (scalar != null)
|
|
1097
|
+
description = unquote(scalar[1]);
|
|
1098
|
+
}
|
|
1099
|
+
return { description, paths };
|
|
955
1100
|
}
|
|
956
|
-
function
|
|
957
|
-
|
|
958
|
-
if (authored.length === 0)
|
|
959
|
-
return;
|
|
960
|
-
ui2.line();
|
|
961
|
-
ui2.line(ui2.theme.accent(ui2.lore.provenance));
|
|
962
|
-
for (const reading of authored)
|
|
963
|
-
ui2.line(` ${reading.marker.padEnd(20)} ${ui2.theme.dim(reading.file)}`);
|
|
964
|
-
ui2.line(ui2.theme.dim(` ${ui2.lore.stillConstructAuthored(authored.length)}`));
|
|
1101
|
+
function firstHeading(body) {
|
|
1102
|
+
return /^#\s+(\S.*)$/m.exec(body)?.[1].trim();
|
|
965
1103
|
}
|
|
966
|
-
function
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
}
|
|
972
|
-
const lift = ui2.lore.levelLift[weakest.level];
|
|
973
|
-
ui2.line(`${ui2.theme.bold(ui2.lore.weakestLink(weakest.id, weakest.level))}${lift == null ? "" : ui2.theme.dim(` \u2014 ${lift}`)}`);
|
|
1104
|
+
function parseClaudeRule(content) {
|
|
1105
|
+
const match = FRONTMATTER.exec(content);
|
|
1106
|
+
const body = match == null ? content : content.slice(match[0].length).replace(/^\n+/, "");
|
|
1107
|
+
const front = match == null ? { paths: [] } : parseFrontmatter(match[1]);
|
|
1108
|
+
return { description: front.description ?? firstHeading(body) ?? "Project rule", paths: front.paths, body };
|
|
974
1109
|
}
|
|
975
|
-
function
|
|
976
|
-
|
|
977
|
-
ui2.flatline("No construct.json here. Run `construct init` first.");
|
|
978
|
-
return 1;
|
|
979
|
-
}
|
|
980
|
-
if (result.harnessProblems.length > 0)
|
|
981
|
-
ui2.glitch("Harness is broken.", result.harnessProblems);
|
|
982
|
-
if (result.missingFiles.length > 0)
|
|
983
|
-
ui2.glitch("Baseline files are missing.", result.missingFiles);
|
|
984
|
-
if (result.missingDiscovery.length > 0)
|
|
985
|
-
ui2.glitch(ui2.lore.discoveryIncomplete, ["", "Missing:", ...result.missingDiscovery.map((marker) => ` ${marker}`), "", "Run: claude \u2192 /construct-discover"]);
|
|
986
|
-
if (result.warnings.length > 0)
|
|
987
|
-
ui2.glitch(ui2.lore.typecheckCaveat, result.warnings);
|
|
988
|
-
if (result.modifiedFiles.length > 0)
|
|
989
|
-
ui2.line(ui2.theme.dim(` ${result.modifiedFiles.length} baseline files modified since init (expected once the project evolves).`));
|
|
990
|
-
if (result.ok)
|
|
991
|
-
ui2.ok(ui2.lore.stable);
|
|
992
|
-
printProvenance(ui2, result.provenance);
|
|
993
|
-
printChecks(ui2, result.checks);
|
|
994
|
-
printWeakestLink(ui2, result.weakestLink);
|
|
995
|
-
return result.ok ? 0 : 1;
|
|
1110
|
+
function isClaudeRule(target) {
|
|
1111
|
+
return target.startsWith(CLAUDE_RULES_DIR) && target.endsWith(".md");
|
|
996
1112
|
}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
function runDoctor(root) {
|
|
1000
|
-
const manifest = readManifest(root);
|
|
1001
|
-
if (manifest == null)
|
|
1002
|
-
return null;
|
|
1003
|
-
const evidence = gatherEvidence(root, manifest);
|
|
1004
|
-
const baseline = baselineVerdict(root, manifest);
|
|
1005
|
-
const problems = harnessProblems(root, manifest, evidence.harness);
|
|
1006
|
-
const checks = [
|
|
1007
|
-
lintPolicyCheck(evidence),
|
|
1008
|
-
constructTestsCheck(evidence),
|
|
1009
|
-
ciCheck(evidence),
|
|
1010
|
-
hookCheck(evidence),
|
|
1011
|
-
redGateCheck(evidence)
|
|
1012
|
-
];
|
|
1013
|
-
return {
|
|
1014
|
-
ok: baseline.missingFiles.length === 0 && problems.length === 0,
|
|
1015
|
-
missingFiles: baseline.missingFiles,
|
|
1016
|
-
modifiedFiles: baseline.modifiedFiles,
|
|
1017
|
-
missingDiscovery: missingDiscovery(root, manifest),
|
|
1018
|
-
provenance: discoveryProvenance(root, manifest),
|
|
1019
|
-
harnessProblems: problems,
|
|
1020
|
-
warnings: typecheckWarnings(manifest.preset, evidence),
|
|
1021
|
-
checks,
|
|
1022
|
-
weakestLink: weakestLink(checks)
|
|
1023
|
-
};
|
|
1113
|
+
function cursorRuleTarget(target) {
|
|
1114
|
+
return `${CURSOR_RULES_DIR}${target.slice(CLAUDE_RULES_DIR.length, -".md".length)}.mdc`;
|
|
1024
1115
|
}
|
|
1116
|
+
function toCursorRule(content) {
|
|
1117
|
+
const rule = parseClaudeRule(content);
|
|
1118
|
+
const always = rule.paths.length === 0;
|
|
1119
|
+
const globs = always ? "" : `globs: ${rule.paths.join(", ")}
|
|
1120
|
+
`;
|
|
1121
|
+
return `---
|
|
1122
|
+
description: ${rule.description}
|
|
1123
|
+
${globs}alwaysApply: ${always}
|
|
1124
|
+
---
|
|
1025
1125
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1126
|
+
${rule.body}`;
|
|
1127
|
+
}
|
|
1128
|
+
function toCursorDiscoveryRule(content) {
|
|
1129
|
+
const rule = parseClaudeRule(content);
|
|
1130
|
+
const body = rule.body.replaceAll("$ARGUMENTS", "what the user asked to (re)discover");
|
|
1131
|
+
return `---
|
|
1132
|
+
description: ${rule.description}
|
|
1133
|
+
alwaysApply: false
|
|
1134
|
+
---
|
|
1034
1135
|
|
|
1035
|
-
|
|
1036
|
-
import { existsSync as existsSync10, readdirSync as readdirSync5 } from "fs";
|
|
1037
|
-
import path11 from "path";
|
|
1038
|
-
var ESLINT_CONFIGS = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yml"];
|
|
1039
|
-
var COMPOSITION_CANDIDATES = ["architecture/composition", "docs/architecture/composition", "docs/composition", "composition"];
|
|
1040
|
-
var OPENAPI_CANDIDATES = ["contracts/api/openapi.yaml", "contracts/api/openapi.yml", "contracts/openapi.yaml", "openapi.yaml", "openapi.yml", "openapi.json", "api/openapi.yaml", "docs/openapi.yaml"];
|
|
1041
|
-
function anyExists(dir, candidates) {
|
|
1042
|
-
return candidates.some((candidate) => existsSync10(path11.join(dir, candidate)));
|
|
1136
|
+
${body}`;
|
|
1043
1137
|
}
|
|
1044
|
-
function
|
|
1045
|
-
|
|
1138
|
+
function mapRulesForTargets(files, ai) {
|
|
1139
|
+
const result = /* @__PURE__ */ new Map();
|
|
1140
|
+
for (const [target, content] of files) {
|
|
1141
|
+
const rule = isClaudeRule(target);
|
|
1142
|
+
const protocol = target === DISCOVERY_PROTOCOL;
|
|
1143
|
+
if (!rule && !protocol) {
|
|
1144
|
+
result.set(target, content);
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
if (ai !== "cursor")
|
|
1148
|
+
result.set(target, content);
|
|
1149
|
+
if (ai !== "claude")
|
|
1150
|
+
result.set(protocol ? CURSOR_DISCOVERY_RULE : cursorRuleTarget(target), protocol ? toCursorDiscoveryRule(content) : toCursorRule(content));
|
|
1151
|
+
}
|
|
1152
|
+
return result;
|
|
1046
1153
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1154
|
+
|
|
1155
|
+
// src/materialize/strategies.ts
|
|
1156
|
+
var MERGE_JSON = /* @__PURE__ */ new Set(["package.json"]);
|
|
1157
|
+
var APPEND_BLOCK = /* @__PURE__ */ new Set([".gitignore", "CLAUDE.md", "AGENTS.md"]);
|
|
1158
|
+
var BLOCK_BEGIN = "<!-- construct:begin -->";
|
|
1159
|
+
var BLOCK_END = "<!-- construct:end -->";
|
|
1160
|
+
var GITIGNORE_BEGIN = "# construct:begin";
|
|
1161
|
+
var GITIGNORE_END = "# construct:end";
|
|
1162
|
+
function strategyFor(target) {
|
|
1163
|
+
const basename = target.split("/").at(-1) ?? target;
|
|
1164
|
+
if (MERGE_JSON.has(basename))
|
|
1165
|
+
return "merge-json";
|
|
1166
|
+
if (APPEND_BLOCK.has(basename))
|
|
1167
|
+
return "append-block";
|
|
1168
|
+
return "create";
|
|
1052
1169
|
}
|
|
1053
|
-
function
|
|
1054
|
-
|
|
1055
|
-
return existsSync10(workflows) && readdirSync5(workflows).some((file) => file.endsWith(".yml") || file.endsWith(".yaml"));
|
|
1170
|
+
function isJsonObject(value) {
|
|
1171
|
+
return typeof value === "object" && value != null && !Array.isArray(value);
|
|
1056
1172
|
}
|
|
1057
|
-
function
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1173
|
+
function mergeJson(existing, incoming, conflicts, prefix = "") {
|
|
1174
|
+
const result = { ...existing };
|
|
1175
|
+
for (const [key, value] of Object.entries(incoming)) {
|
|
1176
|
+
const at = prefix === "" ? key : `${prefix}.${key}`;
|
|
1177
|
+
if (!(key in existing)) {
|
|
1178
|
+
result[key] = value;
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
const current = existing[key];
|
|
1182
|
+
if (isJsonObject(current) && isJsonObject(value)) {
|
|
1183
|
+
result[key] = mergeJson(current, value, conflicts, at);
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
if (JSON.stringify(current) !== JSON.stringify(value))
|
|
1187
|
+
conflicts.push(at);
|
|
1188
|
+
}
|
|
1189
|
+
return result;
|
|
1070
1190
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
var BLOCK_SEQUENCE_ENTRY = /^[ \t]+-[ \t]*\S/;
|
|
1082
|
-
function readIfPresent(file) {
|
|
1083
|
-
if (!existsSync11(file))
|
|
1084
|
-
return null;
|
|
1085
|
-
try {
|
|
1086
|
-
return readFileSync11(file, "utf8");
|
|
1087
|
-
} catch {
|
|
1191
|
+
function blockMarkers(target) {
|
|
1192
|
+
return target.endsWith(".gitignore") ? [GITIGNORE_BEGIN, GITIGNORE_END] : [BLOCK_BEGIN, BLOCK_END];
|
|
1193
|
+
}
|
|
1194
|
+
var DISCOVERY_OPEN = /<!-- construct:discover:([\w-]+) -->/g;
|
|
1195
|
+
function discoveryBlock(document, marker) {
|
|
1196
|
+
const open = `<!-- construct:discover:${marker} -->`;
|
|
1197
|
+
const close = `<!-- /construct:discover:${marker} -->`;
|
|
1198
|
+
const start = document.indexOf(open);
|
|
1199
|
+
const end = document.indexOf(close);
|
|
1200
|
+
if (start === -1 || end === -1 || end < start)
|
|
1088
1201
|
return null;
|
|
1089
|
-
}
|
|
1202
|
+
return { start: start + open.length, end, body: document.slice(start + open.length, end) };
|
|
1090
1203
|
}
|
|
1091
|
-
function
|
|
1092
|
-
|
|
1093
|
-
|
|
1204
|
+
function withoutDiscoveryBodies(document) {
|
|
1205
|
+
let result = document;
|
|
1206
|
+
for (const [, marker] of document.matchAll(DISCOVERY_OPEN)) {
|
|
1207
|
+
const block = discoveryBlock(result, marker);
|
|
1208
|
+
if (block == null)
|
|
1094
1209
|
continue;
|
|
1095
|
-
|
|
1210
|
+
result = `${result.slice(0, block.start)}${result.slice(block.end)}`;
|
|
1096
1211
|
}
|
|
1097
|
-
return
|
|
1098
|
-
}
|
|
1099
|
-
function declaresPnpmPackages(dir) {
|
|
1100
|
-
const content = readIfPresent(path12.join(dir, "pnpm-workspace.yaml"));
|
|
1101
|
-
if (content == null)
|
|
1102
|
-
return false;
|
|
1103
|
-
const match = TOP_LEVEL_PACKAGES_KEY.exec(content);
|
|
1104
|
-
if (match == null)
|
|
1105
|
-
return false;
|
|
1106
|
-
const inline = match[1].trim();
|
|
1107
|
-
if (inline.startsWith("["))
|
|
1108
|
-
return !EMPTY_FLOW_SEQUENCE.test(inline);
|
|
1109
|
-
if (inline !== "")
|
|
1110
|
-
return false;
|
|
1111
|
-
return startsBlockSequence(content.slice(match.index + match[0].length));
|
|
1212
|
+
return result;
|
|
1112
1213
|
}
|
|
1113
|
-
function
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
return false;
|
|
1214
|
+
function preserveDiscovery(existing, incoming) {
|
|
1215
|
+
let result = incoming;
|
|
1216
|
+
for (const [, marker] of existing.matchAll(DISCOVERY_OPEN)) {
|
|
1217
|
+
const previous = discoveryBlock(existing, marker);
|
|
1218
|
+
const next = discoveryBlock(result, marker);
|
|
1219
|
+
if (previous == null || next == null || previous.body.trim() === "" || previous.body.includes("_Not discovered yet"))
|
|
1220
|
+
continue;
|
|
1221
|
+
result = `${result.slice(0, next.start)}${previous.body}${result.slice(next.end)}`;
|
|
1122
1222
|
}
|
|
1123
|
-
|
|
1124
|
-
return workspaces.length > 0;
|
|
1125
|
-
const packages = workspaces?.packages;
|
|
1126
|
-
return Array.isArray(packages) && packages.length > 0;
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
// src/detect/layout.ts
|
|
1130
|
-
var IGNORED_ENTRIES = /* @__PURE__ */ new Set([".git", ".DS_Store", ".gitignore", ".gitattributes", "LICENSE", "README.md", ".idea", ".vscode"]);
|
|
1131
|
-
function isEmptyDir(dir) {
|
|
1132
|
-
if (!existsSync12(dir))
|
|
1133
|
-
return true;
|
|
1134
|
-
return readdirSync6(dir).every((entry) => IGNORED_ENTRIES.has(entry));
|
|
1223
|
+
return result;
|
|
1135
1224
|
}
|
|
1136
|
-
function
|
|
1137
|
-
const
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
if (existsSync12(path13.join(dir, "turbo.json")))
|
|
1143
|
-
tools.push("turbo");
|
|
1144
|
-
if (existsSync12(path13.join(dir, "nx.json")))
|
|
1145
|
-
tools.push("nx");
|
|
1146
|
-
return tools;
|
|
1225
|
+
function substituteBlock(existing, produced, target) {
|
|
1226
|
+
const [begin, end] = blockMarkers(target);
|
|
1227
|
+
const opening = existing.indexOf(begin) + begin.length;
|
|
1228
|
+
const closing = existing.indexOf(end);
|
|
1229
|
+
const incoming = produced.slice(produced.indexOf(begin) + begin.length, produced.indexOf(end));
|
|
1230
|
+
return `${existing.slice(0, opening)}${preserveDiscovery(existing, incoming)}${existing.slice(closing)}`;
|
|
1147
1231
|
}
|
|
1148
|
-
function
|
|
1149
|
-
|
|
1232
|
+
function withoutSecondH1(existing, block) {
|
|
1233
|
+
if (existing.trim() === "" || !/^# /m.test(existing))
|
|
1234
|
+
return block;
|
|
1235
|
+
return block.replace(/^# (.*)$/m, "## $1");
|
|
1150
1236
|
}
|
|
1151
|
-
function
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1237
|
+
function appendBlock(existing, block, target) {
|
|
1238
|
+
const [begin, end] = blockMarkers(target);
|
|
1239
|
+
const wrapped = `${begin}
|
|
1240
|
+
${preserveDiscovery(existing, withoutSecondH1(existing, block)).trimEnd()}
|
|
1241
|
+
${end}
|
|
1242
|
+
`;
|
|
1243
|
+
const start = existing.indexOf(begin);
|
|
1244
|
+
const stop = existing.indexOf(end);
|
|
1245
|
+
if (start !== -1 && stop !== -1 && stop > start)
|
|
1246
|
+
return `${existing.slice(0, start)}${wrapped}${existing.slice(stop + end.length).replace(/^\n/, "")}`;
|
|
1247
|
+
const separator = existing.length === 0 || existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
1248
|
+
return `${existing}${separator}${wrapped}`;
|
|
1158
1249
|
}
|
|
1159
|
-
|
|
1160
|
-
|
|
1250
|
+
|
|
1251
|
+
// src/materialize/templates.ts
|
|
1252
|
+
import { existsSync as existsSync11, readdirSync as readdirSync6, statSync as statSync3 } from "fs";
|
|
1253
|
+
import path13 from "path";
|
|
1254
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1255
|
+
var HERE2 = path13.dirname(fileURLToPath2(import.meta.url));
|
|
1256
|
+
function templatesRoot() {
|
|
1257
|
+
const candidates = [path13.resolve(HERE2, "../templates"), path13.resolve(HERE2, "../../templates")];
|
|
1258
|
+
const found = candidates.find((candidate) => existsSync11(candidate));
|
|
1259
|
+
if (found == null)
|
|
1260
|
+
throw new Error(`templates directory not found next to ${HERE2}`);
|
|
1261
|
+
return found;
|
|
1161
1262
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
return "unknown";
|
|
1263
|
+
var EXISTING_SUFFIX = ".existing.eta";
|
|
1264
|
+
function toTargetPath(relative) {
|
|
1265
|
+
const segments = relative.split(path13.sep).map((segment) => segment.startsWith("_") ? `.${segment.slice(1)}` : segment);
|
|
1266
|
+
const joined = segments.join("/");
|
|
1267
|
+
if (joined.endsWith(EXISTING_SUFFIX))
|
|
1268
|
+
return { target: joined.slice(0, -EXISTING_SUFFIX.length), rendered: true, variant: "existing" };
|
|
1269
|
+
return joined.endsWith(".eta") ? { target: joined.slice(0, -".eta".length), rendered: true, variant: "default" } : { target: joined, rendered: false, variant: "default" };
|
|
1170
1270
|
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
["bun.lockb", "bun"],
|
|
1179
|
-
["bun.lock", "bun"],
|
|
1180
|
-
["yarn.lock", "yarn"],
|
|
1181
|
-
["package-lock.json", "npm"]
|
|
1182
|
-
];
|
|
1183
|
-
function fromPackageManagerField(dir) {
|
|
1184
|
-
const manifest = path14.join(dir, "package.json");
|
|
1185
|
-
if (!existsSync13(manifest))
|
|
1186
|
-
return null;
|
|
1187
|
-
try {
|
|
1188
|
-
const parsed = JSON.parse(readFileSync13(manifest, "utf8"));
|
|
1189
|
-
const name = parsed.packageManager?.split("@")[0];
|
|
1190
|
-
return name === "pnpm" || name === "npm" || name === "yarn" || name === "bun" ? name : null;
|
|
1191
|
-
} catch {
|
|
1192
|
-
return null;
|
|
1271
|
+
function walk(root, current, files) {
|
|
1272
|
+
for (const entry of readdirSync6(current).sort()) {
|
|
1273
|
+
const absolute = path13.join(current, entry);
|
|
1274
|
+
if (statSync3(absolute).isDirectory())
|
|
1275
|
+
walk(root, absolute, files);
|
|
1276
|
+
else if (entry !== ".DS_Store")
|
|
1277
|
+
files.push(path13.relative(root, absolute));
|
|
1193
1278
|
}
|
|
1194
1279
|
}
|
|
1195
|
-
function
|
|
1196
|
-
const
|
|
1197
|
-
if (
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1280
|
+
function listTemplateFiles(group) {
|
|
1281
|
+
const root = path13.join(templatesRoot(), group);
|
|
1282
|
+
if (!existsSync11(root))
|
|
1283
|
+
throw new Error(`template group "${group}" does not exist`);
|
|
1284
|
+
const files = [];
|
|
1285
|
+
walk(root, root, files);
|
|
1286
|
+
return files.map((relative) => {
|
|
1287
|
+
const { target, rendered, variant } = toTargetPath(relative);
|
|
1288
|
+
return { group, source: path13.join(root, relative), target, rendered, variant };
|
|
1289
|
+
});
|
|
1204
1290
|
}
|
|
1205
|
-
var
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
pnpmVersionCache = null;
|
|
1213
|
-
}
|
|
1214
|
-
return pnpmVersionCache;
|
|
1291
|
+
var BLOCK = /^[ \t]*\{\{#(if|unless) (\w+)\}\}[ \t]*\n([\s\S]*?)^[ \t]*\{\{\/\1\}\}[ \t]*\n/gm;
|
|
1292
|
+
var VARIABLE = /\{\{\s*(\w+)\s*\}\}/g;
|
|
1293
|
+
function lookup(vars, name, match) {
|
|
1294
|
+
const value = vars[name];
|
|
1295
|
+
if (value == null)
|
|
1296
|
+
throw new Error(`template variable "${name}" is not defined (${match})`);
|
|
1297
|
+
return value;
|
|
1215
1298
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
function detect(dir) {
|
|
1219
|
-
const root = path15.resolve(dir);
|
|
1220
|
-
const monorepoTools = detectMonorepoTools(root);
|
|
1221
|
-
const workspaceDirs = detectWorkspaceDirs(root);
|
|
1222
|
-
const hasSrc = existsSync14(path15.join(root, "src"));
|
|
1223
|
-
return {
|
|
1224
|
-
dir: root,
|
|
1225
|
-
packageManager: detectPackageManager(root),
|
|
1226
|
-
pnpmVersion: detectPnpmVersion(),
|
|
1227
|
-
layout: detectLayout(root, monorepoTools, workspaceDirs, hasSrc),
|
|
1228
|
-
monorepoTools,
|
|
1229
|
-
workspaceDirs,
|
|
1230
|
-
workspacePackages: detectWorkspacePackages(root, workspaceDirs),
|
|
1231
|
-
hasSrc,
|
|
1232
|
-
nodeMajor: Number(process3.versions.node.split(".")[0]),
|
|
1233
|
-
existing: detectExisting(root)
|
|
1234
|
-
};
|
|
1299
|
+
function isTruthy(value) {
|
|
1300
|
+
return value !== "" && value !== "false";
|
|
1235
1301
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
import path16 from "path";
|
|
1240
|
-
function applyPlan(root, ops) {
|
|
1241
|
-
const written = [];
|
|
1242
|
-
for (const op of ops) {
|
|
1243
|
-
if (op.action === "skip")
|
|
1244
|
-
continue;
|
|
1245
|
-
const absolute = path16.join(root, op.target);
|
|
1246
|
-
mkdirSync(path16.dirname(absolute), { recursive: true });
|
|
1247
|
-
writeFileSync2(absolute, op.content);
|
|
1248
|
-
written.push(op);
|
|
1249
|
-
}
|
|
1250
|
-
return written;
|
|
1302
|
+
function render(template, vars) {
|
|
1303
|
+
const expanded = template.replaceAll(BLOCK, (match, kind, name, body) => isTruthy(lookup(vars, name, match)) === (kind === "if") ? body : "");
|
|
1304
|
+
return expanded.replaceAll(VARIABLE, (match, name) => lookup(vars, name, match));
|
|
1251
1305
|
}
|
|
1252
1306
|
|
|
1253
1307
|
// src/materialize/plan.ts
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
// src/materialize/rules.ts
|
|
1258
|
-
var CLAUDE_RULES_DIR = ".claude/rules/";
|
|
1259
|
-
var CURSOR_RULES_DIR = ".cursor/rules/";
|
|
1260
|
-
var DISCOVERY_PROTOCOL = ".claude/commands/construct-discover.md";
|
|
1261
|
-
var CURSOR_DISCOVERY_RULE = ".cursor/rules/construct-discover.mdc";
|
|
1262
|
-
var FRONTMATTER = /^---\n([\s\S]*?)\n---\n/;
|
|
1263
|
-
function unquote(value) {
|
|
1264
|
-
return value.trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
1265
|
-
}
|
|
1266
|
-
function parseFrontmatter(block) {
|
|
1267
|
-
const paths = [];
|
|
1268
|
-
let description;
|
|
1269
|
-
let inPaths = false;
|
|
1270
|
-
for (const line of block.split("\n")) {
|
|
1271
|
-
const item = /^\s*-\s+(\S.*)$/.exec(line);
|
|
1272
|
-
if (inPaths && item != null) {
|
|
1273
|
-
paths.push(unquote(item[1]));
|
|
1274
|
-
continue;
|
|
1275
|
-
}
|
|
1276
|
-
inPaths = /^paths:\s*$/.test(line);
|
|
1277
|
-
const scalar = /^description:\s*(\S.*)$/.exec(line);
|
|
1278
|
-
if (scalar != null)
|
|
1279
|
-
description = unquote(scalar[1]);
|
|
1280
|
-
}
|
|
1281
|
-
return { description, paths };
|
|
1282
|
-
}
|
|
1283
|
-
function firstHeading(body) {
|
|
1284
|
-
return /^#\s+(\S.*)$/m.exec(body)?.[1].trim();
|
|
1285
|
-
}
|
|
1286
|
-
function parseClaudeRule(content) {
|
|
1287
|
-
const match = FRONTMATTER.exec(content);
|
|
1288
|
-
const body = match == null ? content : content.slice(match[0].length).replace(/^\n+/, "");
|
|
1289
|
-
const front = match == null ? { paths: [] } : parseFrontmatter(match[1]);
|
|
1290
|
-
return { description: front.description ?? firstHeading(body) ?? "Project rule", paths: front.paths, body };
|
|
1291
|
-
}
|
|
1292
|
-
function isClaudeRule(target) {
|
|
1293
|
-
return target.startsWith(CLAUDE_RULES_DIR) && target.endsWith(".md");
|
|
1294
|
-
}
|
|
1295
|
-
function cursorRuleTarget(target) {
|
|
1296
|
-
return `${CURSOR_RULES_DIR}${target.slice(CLAUDE_RULES_DIR.length, -".md".length)}.mdc`;
|
|
1297
|
-
}
|
|
1298
|
-
function toCursorRule(content) {
|
|
1299
|
-
const rule = parseClaudeRule(content);
|
|
1300
|
-
const always = rule.paths.length === 0;
|
|
1301
|
-
const globs = always ? "" : `globs: ${rule.paths.join(", ")}
|
|
1302
|
-
`;
|
|
1303
|
-
return `---
|
|
1304
|
-
description: ${rule.description}
|
|
1305
|
-
${globs}alwaysApply: ${always}
|
|
1306
|
-
---
|
|
1307
|
-
|
|
1308
|
-
${rule.body}`;
|
|
1309
|
-
}
|
|
1310
|
-
function toCursorDiscoveryRule(content) {
|
|
1311
|
-
const rule = parseClaudeRule(content);
|
|
1312
|
-
const body = rule.body.replaceAll("$ARGUMENTS", "what the user asked to (re)discover");
|
|
1313
|
-
return `---
|
|
1314
|
-
description: ${rule.description}
|
|
1315
|
-
alwaysApply: false
|
|
1316
|
-
---
|
|
1317
|
-
|
|
1318
|
-
${body}`;
|
|
1319
|
-
}
|
|
1320
|
-
function mapRulesForTargets(files, ai) {
|
|
1321
|
-
const result = /* @__PURE__ */ new Map();
|
|
1322
|
-
for (const [target, content] of files) {
|
|
1323
|
-
const rule = isClaudeRule(target);
|
|
1324
|
-
const protocol = target === DISCOVERY_PROTOCOL;
|
|
1325
|
-
if (!rule && !protocol) {
|
|
1326
|
-
result.set(target, content);
|
|
1327
|
-
continue;
|
|
1328
|
-
}
|
|
1329
|
-
if (ai !== "cursor")
|
|
1330
|
-
result.set(target, content);
|
|
1331
|
-
if (ai !== "claude")
|
|
1332
|
-
result.set(protocol ? CURSOR_DISCOVERY_RULE : cursorRuleTarget(target), protocol ? toCursorDiscoveryRule(content) : toCursorRule(content));
|
|
1333
|
-
}
|
|
1334
|
-
return result;
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
// src/materialize/strategies.ts
|
|
1338
|
-
var MERGE_JSON = /* @__PURE__ */ new Set(["package.json"]);
|
|
1339
|
-
var APPEND_BLOCK = /* @__PURE__ */ new Set([".gitignore", "CLAUDE.md", "AGENTS.md"]);
|
|
1340
|
-
var BLOCK_BEGIN = "<!-- construct:begin -->";
|
|
1341
|
-
var BLOCK_END = "<!-- construct:end -->";
|
|
1342
|
-
var GITIGNORE_BEGIN = "# construct:begin";
|
|
1343
|
-
var GITIGNORE_END = "# construct:end";
|
|
1344
|
-
function strategyFor(target) {
|
|
1345
|
-
const basename = target.split("/").at(-1) ?? target;
|
|
1346
|
-
if (MERGE_JSON.has(basename))
|
|
1347
|
-
return "merge-json";
|
|
1348
|
-
if (APPEND_BLOCK.has(basename))
|
|
1349
|
-
return "append-block";
|
|
1350
|
-
return "create";
|
|
1351
|
-
}
|
|
1352
|
-
function isObject(value) {
|
|
1353
|
-
return typeof value === "object" && value != null && !Array.isArray(value);
|
|
1354
|
-
}
|
|
1355
|
-
function mergeJson(existing, incoming, conflicts, prefix = "") {
|
|
1356
|
-
const result = { ...existing };
|
|
1357
|
-
for (const [key, value] of Object.entries(incoming)) {
|
|
1358
|
-
const at = prefix === "" ? key : `${prefix}.${key}`;
|
|
1359
|
-
if (!(key in existing)) {
|
|
1360
|
-
result[key] = value;
|
|
1361
|
-
continue;
|
|
1362
|
-
}
|
|
1363
|
-
const current = existing[key];
|
|
1364
|
-
if (isObject(current) && isObject(value)) {
|
|
1365
|
-
result[key] = mergeJson(current, value, conflicts, at);
|
|
1366
|
-
continue;
|
|
1367
|
-
}
|
|
1368
|
-
if (JSON.stringify(current) !== JSON.stringify(value))
|
|
1369
|
-
conflicts.push(at);
|
|
1370
|
-
}
|
|
1371
|
-
return result;
|
|
1372
|
-
}
|
|
1373
|
-
function markersFor(target) {
|
|
1374
|
-
return target.endsWith(".gitignore") ? [GITIGNORE_BEGIN, GITIGNORE_END] : [BLOCK_BEGIN, BLOCK_END];
|
|
1375
|
-
}
|
|
1376
|
-
var DISCOVERY_OPEN = /<!-- construct:discover:([\w-]+) -->/g;
|
|
1377
|
-
function discoveryBlock(document, marker) {
|
|
1378
|
-
const open = `<!-- construct:discover:${marker} -->`;
|
|
1379
|
-
const close = `<!-- /construct:discover:${marker} -->`;
|
|
1380
|
-
const start = document.indexOf(open);
|
|
1381
|
-
const end = document.indexOf(close);
|
|
1382
|
-
if (start === -1 || end === -1 || end < start)
|
|
1383
|
-
return null;
|
|
1384
|
-
return { start: start + open.length, end, body: document.slice(start + open.length, end) };
|
|
1385
|
-
}
|
|
1386
|
-
function preserveDiscovery(existing, incoming) {
|
|
1387
|
-
let result = incoming;
|
|
1388
|
-
for (const [, marker] of existing.matchAll(DISCOVERY_OPEN)) {
|
|
1389
|
-
const previous = discoveryBlock(existing, marker);
|
|
1390
|
-
const next = discoveryBlock(result, marker);
|
|
1391
|
-
if (previous == null || next == null || previous.body.trim() === "" || previous.body.includes("_Not discovered yet"))
|
|
1392
|
-
continue;
|
|
1393
|
-
result = `${result.slice(0, next.start)}${previous.body}${result.slice(next.end)}`;
|
|
1394
|
-
}
|
|
1395
|
-
return result;
|
|
1396
|
-
}
|
|
1397
|
-
function withoutSecondH1(existing, block) {
|
|
1398
|
-
if (existing.trim() === "" || !/^# /m.test(existing))
|
|
1399
|
-
return block;
|
|
1400
|
-
return block.replace(/^# (.*)$/m, "## $1");
|
|
1401
|
-
}
|
|
1402
|
-
function appendBlock(existing, block, target) {
|
|
1403
|
-
const [begin, end] = markersFor(target);
|
|
1404
|
-
const wrapped = `${begin}
|
|
1405
|
-
${preserveDiscovery(existing, withoutSecondH1(existing, block)).trimEnd()}
|
|
1406
|
-
${end}
|
|
1407
|
-
`;
|
|
1408
|
-
const start = existing.indexOf(begin);
|
|
1409
|
-
const stop = existing.indexOf(end);
|
|
1410
|
-
if (start !== -1 && stop !== -1 && stop > start)
|
|
1411
|
-
return `${existing.slice(0, start)}${wrapped}${existing.slice(stop + end.length).replace(/^\n/, "")}`;
|
|
1412
|
-
const separator = existing.length === 0 || existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
1413
|
-
return `${existing}${separator}${wrapped}`;
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
// src/materialize/templates.ts
|
|
1417
|
-
import { existsSync as existsSync15, readdirSync as readdirSync7, statSync as statSync4 } from "fs";
|
|
1418
|
-
import path17 from "path";
|
|
1419
|
-
import { fileURLToPath } from "url";
|
|
1420
|
-
var HERE = path17.dirname(fileURLToPath(import.meta.url));
|
|
1421
|
-
function templatesRoot() {
|
|
1422
|
-
const candidates = [path17.resolve(HERE, "../templates"), path17.resolve(HERE, "../../templates")];
|
|
1423
|
-
const found = candidates.find((candidate) => existsSync15(candidate));
|
|
1424
|
-
if (found == null)
|
|
1425
|
-
throw new Error(`templates directory not found next to ${HERE}`);
|
|
1426
|
-
return found;
|
|
1427
|
-
}
|
|
1428
|
-
var EXISTING_SUFFIX = ".existing.eta";
|
|
1429
|
-
function toTargetPath(relative) {
|
|
1430
|
-
const segments = relative.split(path17.sep).map((segment) => segment.startsWith("_") ? `.${segment.slice(1)}` : segment);
|
|
1431
|
-
const joined = segments.join("/");
|
|
1432
|
-
if (joined.endsWith(EXISTING_SUFFIX))
|
|
1433
|
-
return { target: joined.slice(0, -EXISTING_SUFFIX.length), rendered: true, variant: "existing" };
|
|
1434
|
-
return joined.endsWith(".eta") ? { target: joined.slice(0, -".eta".length), rendered: true, variant: "default" } : { target: joined, rendered: false, variant: "default" };
|
|
1435
|
-
}
|
|
1436
|
-
function walk(root, current, files) {
|
|
1437
|
-
for (const entry of readdirSync7(current).sort()) {
|
|
1438
|
-
const absolute = path17.join(current, entry);
|
|
1439
|
-
if (statSync4(absolute).isDirectory())
|
|
1440
|
-
walk(root, absolute, files);
|
|
1441
|
-
else if (entry !== ".DS_Store")
|
|
1442
|
-
files.push(path17.relative(root, absolute));
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
function listTemplateFiles(group) {
|
|
1446
|
-
const root = path17.join(templatesRoot(), group);
|
|
1447
|
-
if (!existsSync15(root))
|
|
1448
|
-
throw new Error(`template group "${group}" does not exist`);
|
|
1449
|
-
const files = [];
|
|
1450
|
-
walk(root, root, files);
|
|
1451
|
-
return files.map((relative) => {
|
|
1452
|
-
const { target, rendered, variant } = toTargetPath(relative);
|
|
1453
|
-
return { group, source: path17.join(root, relative), target, rendered, variant };
|
|
1454
|
-
});
|
|
1455
|
-
}
|
|
1456
|
-
var BLOCK = /^[ \t]*\{\{#(if|unless) (\w+)\}\}[ \t]*\n([\s\S]*?)^[ \t]*\{\{\/\1\}\}[ \t]*\n/gm;
|
|
1457
|
-
var VARIABLE = /\{\{\s*(\w+)\s*\}\}/g;
|
|
1458
|
-
function lookup(vars, name, match) {
|
|
1459
|
-
const value = vars[name];
|
|
1460
|
-
if (value == null)
|
|
1461
|
-
throw new Error(`template variable "${name}" is not defined (${match})`);
|
|
1462
|
-
return value;
|
|
1463
|
-
}
|
|
1464
|
-
function isTruthy(value) {
|
|
1465
|
-
return value !== "" && value !== "false";
|
|
1466
|
-
}
|
|
1467
|
-
function render(template, vars) {
|
|
1468
|
-
const expanded = template.replaceAll(BLOCK, (match, kind, name, body) => isTruthy(lookup(vars, name, match)) === (kind === "if") ? body : "");
|
|
1469
|
-
return expanded.replaceAll(VARIABLE, (match, name) => lookup(vars, name, match));
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
// src/materialize/plan.ts
|
|
1473
|
-
function toMount(group) {
|
|
1474
|
-
return typeof group === "string" ? { group } : group;
|
|
1308
|
+
function toMount(group) {
|
|
1309
|
+
return typeof group === "string" ? { group } : group;
|
|
1475
1310
|
}
|
|
1476
1311
|
function mountTarget(mount, target) {
|
|
1477
1312
|
return mount.into == null || mount.into === "." ? target : `${mount.into.replace(/\/$/, "")}/${target}`;
|
|
1478
1313
|
}
|
|
1479
1314
|
function readTemplate(source, rendered, vars) {
|
|
1480
|
-
const raw =
|
|
1315
|
+
const raw = readFileSync12(source, "utf8");
|
|
1481
1316
|
return rendered ? render(raw, vars) : raw;
|
|
1482
1317
|
}
|
|
1483
1318
|
var SORTED_SECTIONS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
|
|
@@ -1535,12 +1370,13 @@ function layerJson(earlier, later) {
|
|
|
1535
1370
|
var NOT_ADDED_TO_EXISTING_MANIFEST = ["version"];
|
|
1536
1371
|
function planOne(root, target, content, conflicts, existingVariant) {
|
|
1537
1372
|
const strategy = strategyFor(target);
|
|
1538
|
-
const absolute =
|
|
1539
|
-
const exists =
|
|
1540
|
-
if (!exists)
|
|
1541
|
-
return { target, strategy, action: "create", content:
|
|
1373
|
+
const absolute = path14.join(root, target);
|
|
1374
|
+
const exists = existsSync12(absolute);
|
|
1375
|
+
if (!exists) {
|
|
1376
|
+
return strategy === "append-block" ? { target, strategy, action: "create", content: appendBlock("", content, target), variant: "default" } : { target, strategy, action: "create", content };
|
|
1377
|
+
}
|
|
1542
1378
|
if (strategy === "merge-json") {
|
|
1543
|
-
const existing = JSON.parse(
|
|
1379
|
+
const existing = JSON.parse(readFileSync12(absolute, "utf8"));
|
|
1544
1380
|
const incoming = JSON.parse(content);
|
|
1545
1381
|
for (const key of NOT_ADDED_TO_EXISTING_MANIFEST)
|
|
1546
1382
|
delete incoming[key];
|
|
@@ -1551,8 +1387,14 @@ function planOne(root, target, content, conflicts, existingVariant) {
|
|
|
1551
1387
|
` };
|
|
1552
1388
|
}
|
|
1553
1389
|
if (strategy === "append-block") {
|
|
1554
|
-
const existing =
|
|
1555
|
-
return {
|
|
1390
|
+
const existing = readFileSync12(absolute, "utf8");
|
|
1391
|
+
return {
|
|
1392
|
+
target,
|
|
1393
|
+
strategy,
|
|
1394
|
+
action: "append",
|
|
1395
|
+
content: appendBlock(existing, existingVariant ?? content, target),
|
|
1396
|
+
variant: existingVariant == null ? "default" : "existing"
|
|
1397
|
+
};
|
|
1556
1398
|
}
|
|
1557
1399
|
return { target, strategy, action: "skip", content, note: "exists, review manually" };
|
|
1558
1400
|
}
|
|
@@ -1577,142 +1419,743 @@ function planMaterialize(root, groups, vars, options) {
|
|
|
1577
1419
|
layered.set(target, previous == null || strategyFor(target) !== "merge-json" ? content : layerJson(previous, content));
|
|
1578
1420
|
}
|
|
1579
1421
|
}
|
|
1580
|
-
const ops = [...mapRulesForTargets(layered, options.ai).entries()].map(([target, content]) => planOne(root, target, content, conflicts, existingVariants.get(target))).sort((a, b) => a.target.localeCompare(b.target));
|
|
1581
|
-
return { ops, conflicts, omittedGroups };
|
|
1422
|
+
const ops = [...mapRulesForTargets(layered, options.ai).entries()].map(([target, content]) => planOne(root, target, content, conflicts, existingVariants.get(target))).sort((a, b) => a.target.localeCompare(b.target));
|
|
1423
|
+
return { ops, conflicts, omittedGroups, existingVariants: Object.fromEntries(existingVariants) };
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// src/presets/index.ts
|
|
1427
|
+
var AI_TARGET_LABELS = {
|
|
1428
|
+
claude: "Claude Code",
|
|
1429
|
+
cursor: "Cursor",
|
|
1430
|
+
both: "Claude Code, Cursor"
|
|
1431
|
+
};
|
|
1432
|
+
var EXPRESS_APP = "stacks/express-api/app";
|
|
1433
|
+
var EXPRESS_REPO = "stacks/express-api/repo";
|
|
1434
|
+
var HTTP_CONTRACT = "stacks/http-contract";
|
|
1435
|
+
function sampleWorkspace(scope) {
|
|
1436
|
+
return [
|
|
1437
|
+
{ dir: "packages/shared", name: `${scope}/shared` },
|
|
1438
|
+
{ dir: "apps/api", name: `${scope}/api` }
|
|
1439
|
+
];
|
|
1440
|
+
}
|
|
1441
|
+
function quote(value) {
|
|
1442
|
+
return `'${value}'`;
|
|
1443
|
+
}
|
|
1444
|
+
function renderWorkspacePolicy(packages, sample) {
|
|
1445
|
+
const names = packages.map((pkg) => pkg.name);
|
|
1446
|
+
const allowedFor = (pkg) => {
|
|
1447
|
+
if (sample)
|
|
1448
|
+
return pkg.dir.startsWith("apps/") ? names.filter((name) => name !== pkg.name) : [];
|
|
1449
|
+
return names.filter((name) => name !== pkg.name);
|
|
1450
|
+
};
|
|
1451
|
+
const lines = packages.map((pkg) => ` ${quote(pkg.dir)}: [${allowedFor(pkg).map(quote).join(", ")}],`);
|
|
1452
|
+
return {
|
|
1453
|
+
workspacePackages: `[${names.map(quote).join(", ")}]`,
|
|
1454
|
+
allowedWorkspaceImports: `{
|
|
1455
|
+
${lines.join("\n")}
|
|
1456
|
+
}`
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
var PRESETS = {
|
|
1460
|
+
"node-backend": {
|
|
1461
|
+
id: "node-backend",
|
|
1462
|
+
label: "Node.js backend",
|
|
1463
|
+
description: "Express + TypeScript, contract-first HTTP API, composition root, harness",
|
|
1464
|
+
groups: [
|
|
1465
|
+
"base",
|
|
1466
|
+
"harness",
|
|
1467
|
+
HTTP_CONTRACT,
|
|
1468
|
+
{ group: EXPRESS_APP, onlyWhenEmpty: true },
|
|
1469
|
+
{ group: EXPRESS_REPO, onlyWhenEmpty: true },
|
|
1470
|
+
{ group: "presets/node-backend/sample", onlyWhenEmpty: true },
|
|
1471
|
+
"presets/node-backend/baseline"
|
|
1472
|
+
],
|
|
1473
|
+
contracts: true,
|
|
1474
|
+
available: true,
|
|
1475
|
+
vars: () => ({
|
|
1476
|
+
contractPath: "contracts/api/openapi.yaml",
|
|
1477
|
+
contractTypesOutput: "src/contracts/openapi.ts",
|
|
1478
|
+
contractTypesImport: "./openapi.js",
|
|
1479
|
+
contractPathFromConfig: "../contracts/api/openapi.yaml",
|
|
1480
|
+
appRoot: ""
|
|
1481
|
+
})
|
|
1482
|
+
},
|
|
1483
|
+
"node-frontend": {
|
|
1484
|
+
id: "node-frontend",
|
|
1485
|
+
label: "Node.js frontend",
|
|
1486
|
+
description: "Vite + TypeScript, platform CSS rules, composition root, harness; no API contract",
|
|
1487
|
+
groups: [
|
|
1488
|
+
"base",
|
|
1489
|
+
"harness",
|
|
1490
|
+
{ group: "presets/node-frontend/sample", onlyWhenEmpty: true },
|
|
1491
|
+
"presets/node-frontend/baseline"
|
|
1492
|
+
],
|
|
1493
|
+
contracts: false,
|
|
1494
|
+
available: true,
|
|
1495
|
+
vars: () => ({
|
|
1496
|
+
contractPath: "",
|
|
1497
|
+
contractTypesOutput: ""
|
|
1498
|
+
})
|
|
1499
|
+
},
|
|
1500
|
+
"node-library": {
|
|
1501
|
+
id: "node-library",
|
|
1502
|
+
label: "Node.js library or CLI",
|
|
1503
|
+
description: "TypeScript package with no HTTP contract: architecture policy, composition models, harness",
|
|
1504
|
+
groups: ["base", "harness"],
|
|
1505
|
+
contracts: false,
|
|
1506
|
+
available: true,
|
|
1507
|
+
vars: () => ({
|
|
1508
|
+
contractPath: "",
|
|
1509
|
+
contractTypesOutput: ""
|
|
1510
|
+
})
|
|
1511
|
+
},
|
|
1512
|
+
"monorepo": {
|
|
1513
|
+
id: "monorepo",
|
|
1514
|
+
label: "pnpm monorepo",
|
|
1515
|
+
description: "apps/* + packages/*, catalog:, contract types in packages/shared, dependency policy in lint",
|
|
1516
|
+
groups: [
|
|
1517
|
+
"base",
|
|
1518
|
+
"harness",
|
|
1519
|
+
HTTP_CONTRACT,
|
|
1520
|
+
{ group: EXPRESS_APP, into: "apps/api", onlyWhenEmpty: true },
|
|
1521
|
+
{ group: EXPRESS_REPO, onlyWhenEmpty: true },
|
|
1522
|
+
{ group: "presets/monorepo/sample", onlyWhenEmpty: true },
|
|
1523
|
+
"presets/monorepo/baseline"
|
|
1524
|
+
],
|
|
1525
|
+
contracts: true,
|
|
1526
|
+
available: true,
|
|
1527
|
+
vars: (report, projectName) => {
|
|
1528
|
+
const detected = report.workspacePackages;
|
|
1529
|
+
const packages = detected.length > 0 ? detected : sampleWorkspace(`@${projectName}`);
|
|
1530
|
+
return {
|
|
1531
|
+
contractPath: "contracts/api/openapi.yaml",
|
|
1532
|
+
contractTypesOutput: "packages/shared/src/api/openapi.ts",
|
|
1533
|
+
contractTypesImport: `@${projectName}/shared`,
|
|
1534
|
+
contractPathFromConfig: "../../../contracts/api/openapi.yaml",
|
|
1535
|
+
appRoot: "apps/api/",
|
|
1536
|
+
...renderWorkspacePolicy(packages, detected.length === 0)
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
};
|
|
1541
|
+
var PRESET_IDS = Object.keys(PRESETS);
|
|
1542
|
+
var PRESET_LIST = Object.values(PRESETS);
|
|
1543
|
+
function isPresetId(value) {
|
|
1544
|
+
return value in PRESETS;
|
|
1545
|
+
}
|
|
1546
|
+
function getPreset(id) {
|
|
1547
|
+
return PRESETS[id];
|
|
1548
|
+
}
|
|
1549
|
+
function aiGroups(target) {
|
|
1550
|
+
return target === "both" ? ["ai/shared", "ai/claude", "ai/cursor"] : ["ai/shared", `ai/${target}`];
|
|
1551
|
+
}
|
|
1552
|
+
var DEFAULT_REVIEW_MODEL = "claude-sonnet-5";
|
|
1553
|
+
function reviewGroups(provider) {
|
|
1554
|
+
return provider === "claude" ? ["ai/review"] : [];
|
|
1555
|
+
}
|
|
1556
|
+
function defaultProjectName(dir) {
|
|
1557
|
+
const base = dir.split(/[\\/]/).filter(Boolean).at(-1) ?? "project";
|
|
1558
|
+
return base.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// src/sync/ownership.ts
|
|
1562
|
+
function ownedText(target, content) {
|
|
1563
|
+
if (strategyFor(target) !== "append-block")
|
|
1564
|
+
return content;
|
|
1565
|
+
if (!carriesConstructBlock(target, content))
|
|
1566
|
+
return "";
|
|
1567
|
+
const [begin, end] = blockMarkers(target);
|
|
1568
|
+
return withoutDiscoveryBodies(content.slice(content.indexOf(begin) + begin.length, content.indexOf(end)));
|
|
1569
|
+
}
|
|
1570
|
+
function blockSpansDocument(target, content) {
|
|
1571
|
+
const [begin, end] = blockMarkers(target);
|
|
1572
|
+
const before = content.slice(0, content.indexOf(begin));
|
|
1573
|
+
const after = content.slice(content.indexOf(end) + end.length);
|
|
1574
|
+
return `${before}${after}`.trim() === "";
|
|
1575
|
+
}
|
|
1576
|
+
function carriesConstructBlock(target, content) {
|
|
1577
|
+
const [begin, end] = blockMarkers(target);
|
|
1578
|
+
const start = content.indexOf(begin);
|
|
1579
|
+
const stop = content.indexOf(end);
|
|
1580
|
+
return start !== -1 && stop > start;
|
|
1581
|
+
}
|
|
1582
|
+
function ownedSha(target, content) {
|
|
1583
|
+
return sha256(ownedText(target, content));
|
|
1584
|
+
}
|
|
1585
|
+
function matchesRecordedSha(recorded, target, content) {
|
|
1586
|
+
const asOwnedViewWrittenBySync = ownedSha(target, content);
|
|
1587
|
+
const asWholeFileWrittenByInit = sha256(content);
|
|
1588
|
+
return recorded === asOwnedViewWrittenBySync || recorded === asWholeFileWrittenByInit;
|
|
1589
|
+
}
|
|
1590
|
+
function parseObject(content) {
|
|
1591
|
+
try {
|
|
1592
|
+
const parsed = JSON.parse(content);
|
|
1593
|
+
return isJsonObject(parsed) ? parsed : null;
|
|
1594
|
+
} catch {
|
|
1595
|
+
return null;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
function compareKeys(present, produced, prefix) {
|
|
1599
|
+
const owned = [];
|
|
1600
|
+
for (const [key, value] of Object.entries(produced)) {
|
|
1601
|
+
const at = prefix === "" ? key : `${prefix}.${key}`;
|
|
1602
|
+
if (!(key in present)) {
|
|
1603
|
+
owned.push({ key: at, class: "add" });
|
|
1604
|
+
continue;
|
|
1605
|
+
}
|
|
1606
|
+
const current = present[key];
|
|
1607
|
+
if (isJsonObject(current) && isJsonObject(value)) {
|
|
1608
|
+
owned.push(...compareKeys(current, value, at));
|
|
1609
|
+
continue;
|
|
1610
|
+
}
|
|
1611
|
+
owned.push({ key: at, class: JSON.stringify(current) === JSON.stringify(value) ? "keep" : "conflict" });
|
|
1612
|
+
}
|
|
1613
|
+
return owned;
|
|
1614
|
+
}
|
|
1615
|
+
function ownedKeys(present, produced) {
|
|
1616
|
+
const current = parseObject(present);
|
|
1617
|
+
const template = parseObject(produced);
|
|
1618
|
+
if (current == null || template == null)
|
|
1619
|
+
return null;
|
|
1620
|
+
return compareKeys(current, template, "");
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// src/sync/classify.ts
|
|
1624
|
+
var PATH_CLASSES = ["add", "keep", "update", "conflict", "unknown", "removed", "orphaned", "foreign"];
|
|
1625
|
+
var BLOCK_REPLACED_WHOLE_DISCOVERY_BODIES_CARRIED_OVER = "block-replaced-whole-discovery-bodies-carried-over";
|
|
1626
|
+
function classFromKeys(keys) {
|
|
1627
|
+
if (keys.some((key) => key.class === "conflict"))
|
|
1628
|
+
return "conflict";
|
|
1629
|
+
return keys.some((key) => key.class === "add") ? "update" : "keep";
|
|
1630
|
+
}
|
|
1631
|
+
function compareDeclaredBlock(target, present, produced, variant) {
|
|
1632
|
+
if (!carriesConstructBlock(target, present))
|
|
1633
|
+
return "conflict";
|
|
1634
|
+
if (variant == null)
|
|
1635
|
+
return "unknown";
|
|
1636
|
+
return ownedText(target, present) === ownedText(target, produced) ? "keep" : "update";
|
|
1637
|
+
}
|
|
1638
|
+
function shapeSuggests(target, present) {
|
|
1639
|
+
return blockSpansDocument(target, present) ? "default" : "existing";
|
|
1640
|
+
}
|
|
1641
|
+
function compareOwnedView(target, recordedSha, present, produced) {
|
|
1642
|
+
if (ownedText(target, present) === ownedText(target, produced))
|
|
1643
|
+
return "keep";
|
|
1644
|
+
if (matchesRecordedSha(recordedSha, target, present))
|
|
1645
|
+
return "update";
|
|
1646
|
+
return "conflict";
|
|
1647
|
+
}
|
|
1648
|
+
function classifyPath(state) {
|
|
1649
|
+
const { target, recordedSha, present, produced } = state;
|
|
1650
|
+
const variant = state.variant ?? null;
|
|
1651
|
+
const strategy = strategyFor(target);
|
|
1652
|
+
const classified = (value, keys = []) => ({
|
|
1653
|
+
target,
|
|
1654
|
+
strategy,
|
|
1655
|
+
class: value,
|
|
1656
|
+
keys,
|
|
1657
|
+
writeEffect: writeEffectFor(strategy, value),
|
|
1658
|
+
...strategy === "append-block" ? { variant } : {},
|
|
1659
|
+
...value === "unknown" && present != null ? { shape: shapeSuggests(target, present) } : {}
|
|
1660
|
+
});
|
|
1661
|
+
if (present == null) {
|
|
1662
|
+
if (recordedSha != null)
|
|
1663
|
+
return classified("removed");
|
|
1664
|
+
return produced == null ? null : classified("add");
|
|
1665
|
+
}
|
|
1666
|
+
if (produced == null)
|
|
1667
|
+
return classified(recordedSha == null ? "foreign" : "orphaned");
|
|
1668
|
+
if (strategy === "merge-json") {
|
|
1669
|
+
const keys = ownedKeys(present, produced);
|
|
1670
|
+
if (keys == null)
|
|
1671
|
+
return classified("conflict");
|
|
1672
|
+
return classified(recordedSha == null ? "conflict" : classFromKeys(keys), keys);
|
|
1673
|
+
}
|
|
1674
|
+
if (recordedSha == null)
|
|
1675
|
+
return classified("conflict");
|
|
1676
|
+
if (strategy === "append-block")
|
|
1677
|
+
return classified(compareDeclaredBlock(target, present, produced, variant));
|
|
1678
|
+
return classified(compareOwnedView(target, recordedSha, present, produced));
|
|
1679
|
+
}
|
|
1680
|
+
var WRITABLE_CLASSES = /* @__PURE__ */ new Set(["add", "update"]);
|
|
1681
|
+
var WRITABLE_STRATEGIES = /* @__PURE__ */ new Set(["create", "append-block"]);
|
|
1682
|
+
function willBeWritten(strategy, value) {
|
|
1683
|
+
return WRITABLE_CLASSES.has(value) && WRITABLE_STRATEGIES.has(strategy);
|
|
1684
|
+
}
|
|
1685
|
+
function writeEffectFor(strategy, value) {
|
|
1686
|
+
return strategy === "append-block" && willBeWritten(strategy, value) ? BLOCK_REPLACED_WHOLE_DISCOVERY_BODIES_CARRIED_OVER : null;
|
|
1687
|
+
}
|
|
1688
|
+
function isWritable(classification) {
|
|
1689
|
+
return willBeWritten(classification.strategy, classification.class);
|
|
1690
|
+
}
|
|
1691
|
+
function classifyRepository(state) {
|
|
1692
|
+
const targets = [.../* @__PURE__ */ new Set([...Object.keys(state.recorded), ...Object.keys(state.present), ...Object.keys(state.produced)])].sort();
|
|
1693
|
+
return targets.flatMap((target) => {
|
|
1694
|
+
const classification = classifyPath({
|
|
1695
|
+
target,
|
|
1696
|
+
recordedSha: state.recorded[target] ?? null,
|
|
1697
|
+
present: state.present[target] ?? null,
|
|
1698
|
+
produced: state.produced[target] ?? null,
|
|
1699
|
+
variant: state.variants?.[target] ?? null
|
|
1700
|
+
});
|
|
1701
|
+
return classification == null ? [] : [classification];
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// src/sync/variant.ts
|
|
1706
|
+
function existingForm(target, template) {
|
|
1707
|
+
return appendBlock("", template, target);
|
|
1708
|
+
}
|
|
1709
|
+
function shasOfDefaultForm(target, producedDefault) {
|
|
1710
|
+
return [sha256(producedDefault), ownedSha(target, producedDefault)];
|
|
1711
|
+
}
|
|
1712
|
+
function shasOfExistingForm(target, template, present) {
|
|
1713
|
+
return [sha256(appendBlock(present, template, target)), ownedSha(target, existingForm(target, template))];
|
|
1714
|
+
}
|
|
1715
|
+
function establishVariant(input) {
|
|
1716
|
+
if (input.recordedVariant != null)
|
|
1717
|
+
return { variant: input.recordedVariant, evidence: "recorded" };
|
|
1718
|
+
if (input.existingTemplate == null)
|
|
1719
|
+
return { variant: "default", evidence: "sole-variant" };
|
|
1720
|
+
if (input.recordedSha == null)
|
|
1721
|
+
return null;
|
|
1722
|
+
const reconstructed = [
|
|
1723
|
+
["default", shasOfDefaultForm(input.target, input.producedDefault)],
|
|
1724
|
+
["existing", shasOfExistingForm(input.target, input.existingTemplate, input.present)]
|
|
1725
|
+
];
|
|
1726
|
+
const matched = reconstructed.filter(([, shas]) => shas.includes(input.recordedSha)).map(([variant]) => variant);
|
|
1727
|
+
return matched.length === 1 ? { variant: matched[0], evidence: "reconstructed" } : null;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// src/sync/replay.ts
|
|
1731
|
+
var NO_TREE_TO_PLAN_AGAINST = path15.join(tmpdir(), "mikoshi-construct-replay-renders-against-no-tree");
|
|
1732
|
+
function replayedGroups(manifest) {
|
|
1733
|
+
const preset = getPreset(manifest.preset);
|
|
1734
|
+
return [...preset.groups, ...aiGroups(manifest.ai), ...reviewGroups(manifest.review?.provider ?? "none")];
|
|
1735
|
+
}
|
|
1736
|
+
function varsRecordingMisses(manifest, version, facts, missed) {
|
|
1737
|
+
const asRecordedExceptTheRunningVersion = { ...manifest.vars, constructVersion: version };
|
|
1738
|
+
return new Proxy(asRecordedExceptTheRunningVersion, {
|
|
1739
|
+
get(target, key) {
|
|
1740
|
+
if (typeof key !== "string")
|
|
1741
|
+
return Reflect.get(target, key);
|
|
1742
|
+
const value = target[key];
|
|
1743
|
+
if (value != null)
|
|
1744
|
+
return value;
|
|
1745
|
+
const established = facts[key];
|
|
1746
|
+
if (established != null)
|
|
1747
|
+
return established;
|
|
1748
|
+
missed.add(key);
|
|
1749
|
+
return "";
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
function missingVariables(manifest, missed) {
|
|
1754
|
+
const names = [...missed].sort().join(", ");
|
|
1755
|
+
return `construct.json was written by construct ${manifest.construct} and carries no value for ${names}, which today's ${manifest.preset} templates render. Add ${missed.size === 1 ? "it" : "each of them"} under "vars" in construct.json and run the sync again.`;
|
|
1756
|
+
}
|
|
1757
|
+
function contentByTarget(ops) {
|
|
1758
|
+
return Object.fromEntries(ops.map((op) => [op.target, op.content]));
|
|
1759
|
+
}
|
|
1760
|
+
function producedByTemplates(manifest, vars, recorded) {
|
|
1761
|
+
const groups = replayedGroups(manifest);
|
|
1762
|
+
const plan = (emptyTarget) => planMaterialize(NO_TREE_TO_PLAN_AGAINST, groups, vars, { emptyTarget, ai: manifest.ai });
|
|
1763
|
+
const withoutSamples = plan(false);
|
|
1764
|
+
const kept = contentByTarget(withoutSamples.ops);
|
|
1765
|
+
if (withoutSamples.omittedGroups.length === 0)
|
|
1766
|
+
return { produced: kept, existingVariants: withoutSamples.existingVariants };
|
|
1767
|
+
const withSamples = plan(true);
|
|
1768
|
+
const sampled = contentByTarget(withSamples.ops);
|
|
1769
|
+
const sampleWasMaterialized = Object.keys(sampled).some((target) => !(target in kept) && recorded[target] != null);
|
|
1770
|
+
return sampleWasMaterialized ? { produced: sampled, existingVariants: withSamples.existingVariants } : { produced: kept, existingVariants: withoutSamples.existingVariants };
|
|
1771
|
+
}
|
|
1772
|
+
function establishedVariants(input) {
|
|
1773
|
+
const recordedVariant = recordedVariants(input.manifest);
|
|
1774
|
+
const established = {};
|
|
1775
|
+
for (const [target, producedDefault] of Object.entries(input.templates.produced)) {
|
|
1776
|
+
const present = input.present[target];
|
|
1777
|
+
if (strategyFor(target) !== "append-block" || present == null)
|
|
1778
|
+
continue;
|
|
1779
|
+
const variant = establishVariant({
|
|
1780
|
+
target,
|
|
1781
|
+
recordedVariant: recordedVariant[target] ?? null,
|
|
1782
|
+
recordedSha: input.recorded[target] ?? null,
|
|
1783
|
+
present,
|
|
1784
|
+
producedDefault,
|
|
1785
|
+
existingTemplate: input.templates.existingVariants[target] ?? null
|
|
1786
|
+
});
|
|
1787
|
+
if (variant != null)
|
|
1788
|
+
established[target] = variant;
|
|
1789
|
+
}
|
|
1790
|
+
return established;
|
|
1791
|
+
}
|
|
1792
|
+
function producedInTheVariantThatWroteIt(templates, variants) {
|
|
1793
|
+
const produced = { ...templates.produced };
|
|
1794
|
+
for (const [target, established] of Object.entries(variants)) {
|
|
1795
|
+
const template = templates.existingVariants[target];
|
|
1796
|
+
if (established.variant === "existing" && template != null)
|
|
1797
|
+
produced[target] = existingForm(target, template);
|
|
1798
|
+
}
|
|
1799
|
+
return produced;
|
|
1800
|
+
}
|
|
1801
|
+
function presentInTree(root, targets) {
|
|
1802
|
+
const present = {};
|
|
1803
|
+
for (const target of new Set(targets)) {
|
|
1804
|
+
const absolute = path15.join(root, target);
|
|
1805
|
+
if (existsSync13(absolute))
|
|
1806
|
+
present[target] = readFileSync13(absolute, "utf8");
|
|
1807
|
+
}
|
|
1808
|
+
return present;
|
|
1809
|
+
}
|
|
1810
|
+
function replay(input) {
|
|
1811
|
+
const missed = /* @__PURE__ */ new Set();
|
|
1812
|
+
const vars = varsRecordingMisses(input.manifest, input.version, input.facts, missed);
|
|
1813
|
+
const recorded = recordedShas(input.manifest);
|
|
1814
|
+
const templates = producedByTemplates(input.manifest, vars, recorded);
|
|
1815
|
+
if (missed.size > 0)
|
|
1816
|
+
throw new Error(missingVariables(input.manifest, missed));
|
|
1817
|
+
const present = presentInTree(input.root, [...Object.keys(recorded), ...Object.keys(templates.produced)]);
|
|
1818
|
+
const variants = establishedVariants({ manifest: input.manifest, recorded, present, templates });
|
|
1819
|
+
const produced = producedInTheVariantThatWroteIt(templates, variants);
|
|
1820
|
+
return {
|
|
1821
|
+
fromVersion: input.manifest.construct,
|
|
1822
|
+
toVersion: input.version,
|
|
1823
|
+
present,
|
|
1824
|
+
produced,
|
|
1825
|
+
variants,
|
|
1826
|
+
classifications: classifyRepository({ recorded, present, produced, variants })
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// src/sync/write.ts
|
|
1831
|
+
var PENDING_CLASSES = ["add", "update"];
|
|
1832
|
+
function isPending(classification) {
|
|
1833
|
+
return PENDING_CLASSES.includes(classification.class);
|
|
1834
|
+
}
|
|
1835
|
+
function contentToWrite(classification, input) {
|
|
1836
|
+
const produced = input.produced[classification.target] ?? "";
|
|
1837
|
+
const present = input.present[classification.target];
|
|
1838
|
+
if (classification.strategy !== "append-block" || present == null)
|
|
1839
|
+
return produced;
|
|
1840
|
+
return substituteBlock(present, produced, classification.target);
|
|
1841
|
+
}
|
|
1842
|
+
function variantWritten(classification, input) {
|
|
1843
|
+
if (classification.strategy !== "append-block")
|
|
1844
|
+
return null;
|
|
1845
|
+
return classification.variant?.variant ?? (input.present[classification.target] == null ? "default" : null);
|
|
1846
|
+
}
|
|
1847
|
+
function plannedWrite(classification, input) {
|
|
1848
|
+
const content = contentToWrite(classification, input);
|
|
1849
|
+
return {
|
|
1850
|
+
target: classification.target,
|
|
1851
|
+
strategy: classification.strategy,
|
|
1852
|
+
content,
|
|
1853
|
+
ownedSha: ownedSha(classification.target, content),
|
|
1854
|
+
variant: variantWritten(classification, input),
|
|
1855
|
+
writeEffect: classification.writeEffect
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
function planWrites(input) {
|
|
1859
|
+
const writes = [];
|
|
1860
|
+
const refused = [];
|
|
1861
|
+
for (const classification of input.classifications) {
|
|
1862
|
+
if (isWritable(classification))
|
|
1863
|
+
writes.push(plannedWrite(classification, input));
|
|
1864
|
+
else if (isPending(classification))
|
|
1865
|
+
refused.push(classification);
|
|
1866
|
+
}
|
|
1867
|
+
return { writes, refused };
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// src/commands/doctor/version-gap.ts
|
|
1871
|
+
function versionGap(root, manifest, version) {
|
|
1872
|
+
try {
|
|
1873
|
+
const { fromVersion, toVersion, classifications } = replay({ root, manifest, version, facts: factsTheRepositoryEstablishes(root) });
|
|
1874
|
+
return { materializedBy: fromVersion, readBy: toVersion, pending: classifications.filter(isPending).length };
|
|
1875
|
+
} catch {
|
|
1876
|
+
return { materializedBy: manifest.construct, readBy: version, pending: null };
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// src/commands/doctor/report.ts
|
|
1881
|
+
function checkLine(ui2, check) {
|
|
1882
|
+
return ` ${check.id.padEnd(16)} ${check.level} ${check.state.padEnd(8)} ${ui2.theme.dim(check.evidence)}`;
|
|
1883
|
+
}
|
|
1884
|
+
function printChecks(ui2, checks) {
|
|
1885
|
+
ui2.line();
|
|
1886
|
+
ui2.line(ui2.theme.accent(ui2.lore.enforcement));
|
|
1887
|
+
for (const check of checks)
|
|
1888
|
+
ui2.line(checkLine(ui2, check));
|
|
1889
|
+
}
|
|
1890
|
+
function printProvenance(ui2, provenance) {
|
|
1891
|
+
const authored = constructAuthored(provenance);
|
|
1892
|
+
if (authored.length === 0)
|
|
1893
|
+
return;
|
|
1894
|
+
ui2.line();
|
|
1895
|
+
ui2.line(ui2.theme.accent(ui2.lore.provenance));
|
|
1896
|
+
for (const reading of authored)
|
|
1897
|
+
ui2.line(` ${reading.marker.padEnd(20)} ${ui2.theme.dim(reading.file)}`);
|
|
1898
|
+
ui2.line(ui2.theme.dim(` ${ui2.lore.stillConstructAuthored(authored.length)}`));
|
|
1899
|
+
}
|
|
1900
|
+
function gapReading(ui2, gap) {
|
|
1901
|
+
if (gap.pending == null)
|
|
1902
|
+
return ui2.lore.baselineGapUnknown;
|
|
1903
|
+
return gap.pending === 0 ? ui2.lore.baselineCurrent : ui2.lore.baselineMoved(gap.pending);
|
|
1904
|
+
}
|
|
1905
|
+
function printVersionGap(ui2, gap) {
|
|
1906
|
+
ui2.line(ui2.theme.dim(` ${ui2.lore.syncVersionGap(gap.materializedBy, gap.readBy)}`));
|
|
1907
|
+
ui2.line(ui2.theme.dim(` ${gapReading(ui2, gap)}`));
|
|
1908
|
+
}
|
|
1909
|
+
function printWeakestLink(ui2, weakest) {
|
|
1910
|
+
ui2.line();
|
|
1911
|
+
if (weakest == null) {
|
|
1912
|
+
ui2.line(ui2.theme.bold(ui2.lore.weakestLinkNone));
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
const lift = ui2.lore.levelLift[weakest.level];
|
|
1916
|
+
ui2.line(`${ui2.theme.bold(ui2.lore.weakestLink(weakest.id, weakest.level))}${lift == null ? "" : ui2.theme.dim(` \u2014 ${lift}`)}`);
|
|
1917
|
+
}
|
|
1918
|
+
function printDoctor(ui2, result) {
|
|
1919
|
+
if (result == null) {
|
|
1920
|
+
ui2.flatline("No construct.json here. Run `construct init` first.");
|
|
1921
|
+
return 1;
|
|
1922
|
+
}
|
|
1923
|
+
if (result.harnessProblems.length > 0)
|
|
1924
|
+
ui2.glitch("Harness is broken.", result.harnessProblems);
|
|
1925
|
+
if (result.missingFiles.length > 0)
|
|
1926
|
+
ui2.glitch("Baseline files are missing.", result.missingFiles);
|
|
1927
|
+
if (result.missingDiscovery.length > 0)
|
|
1928
|
+
ui2.glitch(ui2.lore.discoveryIncomplete, ["", "Missing:", ...result.missingDiscovery.map((marker) => ` ${marker}`), "", "Run: claude \u2192 /construct-discover"]);
|
|
1929
|
+
if (result.warnings.length > 0)
|
|
1930
|
+
ui2.glitch(ui2.lore.typecheckCaveat, result.warnings);
|
|
1931
|
+
if (result.modifiedFiles.length > 0)
|
|
1932
|
+
ui2.line(ui2.theme.dim(` ${result.modifiedFiles.length} baseline files modified since init (expected once the project evolves).`));
|
|
1933
|
+
printVersionGap(ui2, result.versionGap);
|
|
1934
|
+
if (result.ok)
|
|
1935
|
+
ui2.ok(ui2.lore.stable);
|
|
1936
|
+
printProvenance(ui2, result.provenance);
|
|
1937
|
+
printChecks(ui2, result.checks);
|
|
1938
|
+
printWeakestLink(ui2, result.weakestLink);
|
|
1939
|
+
return result.ok ? 0 : 1;
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// src/commands/doctor/index.ts
|
|
1943
|
+
function runDoctor(root, version = VERSION) {
|
|
1944
|
+
const manifest = readManifest(root);
|
|
1945
|
+
if (manifest == null)
|
|
1946
|
+
return null;
|
|
1947
|
+
const evidence = gatherEvidence(root, manifest);
|
|
1948
|
+
const baseline = baselineVerdict(root, manifest);
|
|
1949
|
+
const problems = harnessProblems(root, manifest, evidence.harness);
|
|
1950
|
+
const checks = [
|
|
1951
|
+
lintPolicyCheck(evidence),
|
|
1952
|
+
constructTestsCheck(evidence),
|
|
1953
|
+
ciCheck(evidence),
|
|
1954
|
+
hookCheck(evidence),
|
|
1955
|
+
redGateCheck(evidence)
|
|
1956
|
+
];
|
|
1957
|
+
return {
|
|
1958
|
+
ok: baseline.missingFiles.length === 0 && problems.length === 0,
|
|
1959
|
+
missingFiles: baseline.missingFiles,
|
|
1960
|
+
modifiedFiles: baseline.modifiedFiles,
|
|
1961
|
+
missingDiscovery: missingDiscovery(root, manifest),
|
|
1962
|
+
provenance: discoveryProvenance(root, manifest),
|
|
1963
|
+
harnessProblems: problems,
|
|
1964
|
+
warnings: typecheckWarnings(manifest.preset, evidence),
|
|
1965
|
+
checks,
|
|
1966
|
+
weakestLink: weakestLink(checks),
|
|
1967
|
+
versionGap: versionGap(root, manifest, version)
|
|
1968
|
+
};
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
// src/commands/init.ts
|
|
1972
|
+
import { mkdirSync as mkdirSync2 } from "fs";
|
|
1973
|
+
import path21 from "path";
|
|
1974
|
+
|
|
1975
|
+
// src/detect/index.ts
|
|
1976
|
+
import { existsSync as existsSync17 } from "fs";
|
|
1977
|
+
import path19 from "path";
|
|
1978
|
+
import process3 from "process";
|
|
1979
|
+
|
|
1980
|
+
// src/detect/layout.ts
|
|
1981
|
+
import { existsSync as existsSync15, readdirSync as readdirSync7, readFileSync as readFileSync15, statSync as statSync4 } from "fs";
|
|
1982
|
+
import path17 from "path";
|
|
1983
|
+
|
|
1984
|
+
// src/detect/workspaces.ts
|
|
1985
|
+
import { existsSync as existsSync14, readFileSync as readFileSync14 } from "fs";
|
|
1986
|
+
import path16 from "path";
|
|
1987
|
+
var TOP_LEVEL_PACKAGES_KEY = /^packages:(.*)$/m;
|
|
1988
|
+
var EMPTY_FLOW_SEQUENCE = /^\[\s*\]$/;
|
|
1989
|
+
var BLOCK_SEQUENCE_ENTRY = /^[ \t]+-[ \t]*\S/;
|
|
1990
|
+
function readIfPresent(file) {
|
|
1991
|
+
if (!existsSync14(file))
|
|
1992
|
+
return null;
|
|
1993
|
+
try {
|
|
1994
|
+
return readFileSync14(file, "utf8");
|
|
1995
|
+
} catch {
|
|
1996
|
+
return null;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
function startsBlockSequence(rest) {
|
|
2000
|
+
for (const line of rest.split("\n")) {
|
|
2001
|
+
if (line.trim() === "" || line.trimStart().startsWith("#"))
|
|
2002
|
+
continue;
|
|
2003
|
+
return BLOCK_SEQUENCE_ENTRY.test(line);
|
|
2004
|
+
}
|
|
2005
|
+
return false;
|
|
2006
|
+
}
|
|
2007
|
+
function declaresPnpmPackages(dir) {
|
|
2008
|
+
const content = readIfPresent(path16.join(dir, "pnpm-workspace.yaml"));
|
|
2009
|
+
if (content == null)
|
|
2010
|
+
return false;
|
|
2011
|
+
const match = TOP_LEVEL_PACKAGES_KEY.exec(content);
|
|
2012
|
+
if (match == null)
|
|
2013
|
+
return false;
|
|
2014
|
+
const inline = match[1].trim();
|
|
2015
|
+
if (inline.startsWith("["))
|
|
2016
|
+
return !EMPTY_FLOW_SEQUENCE.test(inline);
|
|
2017
|
+
if (inline !== "")
|
|
2018
|
+
return false;
|
|
2019
|
+
return startsBlockSequence(content.slice(match.index + match[0].length));
|
|
2020
|
+
}
|
|
2021
|
+
function declaresNpmWorkspaces(dir) {
|
|
2022
|
+
const content = readIfPresent(path16.join(dir, "package.json"));
|
|
2023
|
+
if (content == null)
|
|
2024
|
+
return false;
|
|
2025
|
+
let workspaces;
|
|
2026
|
+
try {
|
|
2027
|
+
({ workspaces } = JSON.parse(content));
|
|
2028
|
+
} catch {
|
|
2029
|
+
return false;
|
|
2030
|
+
}
|
|
2031
|
+
if (Array.isArray(workspaces))
|
|
2032
|
+
return workspaces.length > 0;
|
|
2033
|
+
const packages = workspaces?.packages;
|
|
2034
|
+
return Array.isArray(packages) && packages.length > 0;
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// src/detect/layout.ts
|
|
2038
|
+
var IGNORED_ENTRIES = /* @__PURE__ */ new Set([".git", ".DS_Store", ".gitignore", ".gitattributes", "LICENSE", "README.md", ".idea", ".vscode"]);
|
|
2039
|
+
function isEmptyDir(dir) {
|
|
2040
|
+
if (!existsSync15(dir))
|
|
2041
|
+
return true;
|
|
2042
|
+
return readdirSync7(dir).every((entry) => IGNORED_ENTRIES.has(entry));
|
|
2043
|
+
}
|
|
2044
|
+
function detectMonorepoTools(dir) {
|
|
2045
|
+
const tools = [];
|
|
2046
|
+
if (declaresPnpmPackages(dir))
|
|
2047
|
+
tools.push("pnpm-workspace");
|
|
2048
|
+
if (declaresNpmWorkspaces(dir))
|
|
2049
|
+
tools.push("npm-workspaces");
|
|
2050
|
+
if (existsSync15(path17.join(dir, "turbo.json")))
|
|
2051
|
+
tools.push("turbo");
|
|
2052
|
+
if (existsSync15(path17.join(dir, "nx.json")))
|
|
2053
|
+
tools.push("nx");
|
|
2054
|
+
return tools;
|
|
2055
|
+
}
|
|
2056
|
+
function detectWorkspaceDirs(dir) {
|
|
2057
|
+
return ["apps", "packages", "libs", "services"].filter((name) => existsSync15(path17.join(dir, name)) && statSync4(path17.join(dir, name)).isDirectory());
|
|
2058
|
+
}
|
|
2059
|
+
function packageName(dir) {
|
|
2060
|
+
try {
|
|
2061
|
+
const parsed = JSON.parse(readFileSync15(path17.join(dir, "package.json"), "utf8"));
|
|
2062
|
+
return typeof parsed.name === "string" && parsed.name !== "" ? parsed.name : null;
|
|
2063
|
+
} catch {
|
|
2064
|
+
return null;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
function detectWorkspacePackages(root, workspaceDirs) {
|
|
2068
|
+
return workspaceDirs.flatMap((parent) => readdirSync7(path17.join(root, parent)).sort().map((entry) => `${parent}/${entry}`).filter((dir) => existsSync15(path17.join(root, dir, "package.json"))).map((dir) => ({ dir, name: packageName(path17.join(root, dir)) ?? dir.split("/").at(-1) ?? dir })));
|
|
2069
|
+
}
|
|
2070
|
+
function detectLayout(dir, monorepoTools, workspaceDirs, hasSrc) {
|
|
2071
|
+
if (isEmptyDir(dir))
|
|
2072
|
+
return "empty";
|
|
2073
|
+
if (monorepoTools.length > 0 || workspaceDirs.length > 0)
|
|
2074
|
+
return "monorepo";
|
|
2075
|
+
if (hasSrc || existsSync15(path17.join(dir, "package.json")))
|
|
2076
|
+
return "single";
|
|
2077
|
+
return "unknown";
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// src/detect/package-manager.ts
|
|
2081
|
+
import { execFileSync } from "child_process";
|
|
2082
|
+
import { existsSync as existsSync16, readFileSync as readFileSync16 } from "fs";
|
|
2083
|
+
import path18 from "path";
|
|
2084
|
+
var LOCKFILES = [
|
|
2085
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
2086
|
+
["bun.lockb", "bun"],
|
|
2087
|
+
["bun.lock", "bun"],
|
|
2088
|
+
["yarn.lock", "yarn"],
|
|
2089
|
+
["package-lock.json", "npm"]
|
|
2090
|
+
];
|
|
2091
|
+
function fromPackageManagerField(dir) {
|
|
2092
|
+
const manifest = path18.join(dir, "package.json");
|
|
2093
|
+
if (!existsSync16(manifest))
|
|
2094
|
+
return null;
|
|
2095
|
+
try {
|
|
2096
|
+
const parsed = JSON.parse(readFileSync16(manifest, "utf8"));
|
|
2097
|
+
const name = parsed.packageManager?.split("@")[0];
|
|
2098
|
+
return name === "pnpm" || name === "npm" || name === "yarn" || name === "bun" ? name : null;
|
|
2099
|
+
} catch {
|
|
2100
|
+
return null;
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
function detectPackageManager(dir) {
|
|
2104
|
+
const declared = fromPackageManagerField(dir);
|
|
2105
|
+
if (declared != null)
|
|
2106
|
+
return declared;
|
|
2107
|
+
for (const [lockfile, manager] of LOCKFILES) {
|
|
2108
|
+
if (existsSync16(path18.join(dir, lockfile)))
|
|
2109
|
+
return manager;
|
|
2110
|
+
}
|
|
2111
|
+
return existsSync16(path18.join(dir, "package.json")) ? "npm" : "none";
|
|
2112
|
+
}
|
|
2113
|
+
var pnpmVersionCache;
|
|
2114
|
+
function detectPnpmVersion() {
|
|
2115
|
+
if (pnpmVersionCache !== void 0)
|
|
2116
|
+
return pnpmVersionCache;
|
|
2117
|
+
try {
|
|
2118
|
+
pnpmVersionCache = execFileSync("pnpm", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
|
|
2119
|
+
} catch {
|
|
2120
|
+
pnpmVersionCache = null;
|
|
2121
|
+
}
|
|
2122
|
+
return pnpmVersionCache;
|
|
1582
2123
|
}
|
|
1583
2124
|
|
|
1584
|
-
// src/
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
var EXPRESS_APP = "stacks/express-api/app";
|
|
1591
|
-
var EXPRESS_REPO = "stacks/express-api/repo";
|
|
1592
|
-
var HTTP_CONTRACT = "stacks/http-contract";
|
|
1593
|
-
function sampleWorkspace(scope) {
|
|
1594
|
-
return [
|
|
1595
|
-
{ dir: "packages/shared", name: `${scope}/shared` },
|
|
1596
|
-
{ dir: "apps/api", name: `${scope}/api` }
|
|
1597
|
-
];
|
|
1598
|
-
}
|
|
1599
|
-
function quote(value) {
|
|
1600
|
-
return `'${value}'`;
|
|
1601
|
-
}
|
|
1602
|
-
function renderWorkspacePolicy(packages, sample) {
|
|
1603
|
-
const names = packages.map((pkg) => pkg.name);
|
|
1604
|
-
const allowedFor = (pkg) => {
|
|
1605
|
-
if (sample)
|
|
1606
|
-
return pkg.dir.startsWith("apps/") ? names.filter((name) => name !== pkg.name) : [];
|
|
1607
|
-
return names.filter((name) => name !== pkg.name);
|
|
1608
|
-
};
|
|
1609
|
-
const lines = packages.map((pkg) => ` ${quote(pkg.dir)}: [${allowedFor(pkg).map(quote).join(", ")}],`);
|
|
2125
|
+
// src/detect/index.ts
|
|
2126
|
+
function detect(dir) {
|
|
2127
|
+
const root = path19.resolve(dir);
|
|
2128
|
+
const monorepoTools = detectMonorepoTools(root);
|
|
2129
|
+
const workspaceDirs = detectWorkspaceDirs(root);
|
|
2130
|
+
const hasSrc = existsSync17(path19.join(root, "src"));
|
|
1610
2131
|
return {
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
2132
|
+
dir: root,
|
|
2133
|
+
packageManager: detectPackageManager(root),
|
|
2134
|
+
pnpmVersion: detectPnpmVersion(),
|
|
2135
|
+
layout: detectLayout(root, monorepoTools, workspaceDirs, hasSrc),
|
|
2136
|
+
monorepoTools,
|
|
2137
|
+
workspaceDirs,
|
|
2138
|
+
workspacePackages: detectWorkspacePackages(root, workspaceDirs),
|
|
2139
|
+
hasSrc,
|
|
2140
|
+
nodeMajor: Number(process3.versions.node.split(".")[0]),
|
|
2141
|
+
existing: detectExisting(root)
|
|
1615
2142
|
};
|
|
1616
2143
|
}
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
contracts: true,
|
|
1631
|
-
available: true,
|
|
1632
|
-
vars: () => ({
|
|
1633
|
-
contractPath: "contracts/api/openapi.yaml",
|
|
1634
|
-
contractTypesOutput: "src/contracts/openapi.ts",
|
|
1635
|
-
contractTypesImport: "./openapi.js",
|
|
1636
|
-
contractPathFromConfig: "../contracts/api/openapi.yaml",
|
|
1637
|
-
appRoot: ""
|
|
1638
|
-
})
|
|
1639
|
-
},
|
|
1640
|
-
"node-frontend": {
|
|
1641
|
-
id: "node-frontend",
|
|
1642
|
-
label: "Node.js frontend",
|
|
1643
|
-
description: "Vite + TypeScript, platform CSS rules, composition root, harness; no API contract",
|
|
1644
|
-
groups: [
|
|
1645
|
-
"base",
|
|
1646
|
-
"harness",
|
|
1647
|
-
{ group: "presets/node-frontend/sample", onlyWhenEmpty: true },
|
|
1648
|
-
"presets/node-frontend/baseline"
|
|
1649
|
-
],
|
|
1650
|
-
contracts: false,
|
|
1651
|
-
available: true,
|
|
1652
|
-
vars: () => ({
|
|
1653
|
-
contractPath: "",
|
|
1654
|
-
contractTypesOutput: ""
|
|
1655
|
-
})
|
|
1656
|
-
},
|
|
1657
|
-
"node-library": {
|
|
1658
|
-
id: "node-library",
|
|
1659
|
-
label: "Node.js library or CLI",
|
|
1660
|
-
description: "TypeScript package with no HTTP contract: architecture policy, composition models, harness",
|
|
1661
|
-
groups: ["base", "harness"],
|
|
1662
|
-
contracts: false,
|
|
1663
|
-
available: true,
|
|
1664
|
-
vars: () => ({
|
|
1665
|
-
contractPath: "",
|
|
1666
|
-
contractTypesOutput: ""
|
|
1667
|
-
})
|
|
1668
|
-
},
|
|
1669
|
-
"monorepo": {
|
|
1670
|
-
id: "monorepo",
|
|
1671
|
-
label: "pnpm monorepo",
|
|
1672
|
-
description: "apps/* + packages/*, catalog:, contract types in packages/shared, dependency policy in lint",
|
|
1673
|
-
groups: [
|
|
1674
|
-
"base",
|
|
1675
|
-
"harness",
|
|
1676
|
-
HTTP_CONTRACT,
|
|
1677
|
-
{ group: EXPRESS_APP, into: "apps/api", onlyWhenEmpty: true },
|
|
1678
|
-
{ group: EXPRESS_REPO, onlyWhenEmpty: true },
|
|
1679
|
-
{ group: "presets/monorepo/sample", onlyWhenEmpty: true },
|
|
1680
|
-
"presets/monorepo/baseline"
|
|
1681
|
-
],
|
|
1682
|
-
contracts: true,
|
|
1683
|
-
available: true,
|
|
1684
|
-
vars: (report, projectName) => {
|
|
1685
|
-
const detected = report.workspacePackages;
|
|
1686
|
-
const packages = detected.length > 0 ? detected : sampleWorkspace(`@${projectName}`);
|
|
1687
|
-
return {
|
|
1688
|
-
contractPath: "contracts/api/openapi.yaml",
|
|
1689
|
-
contractTypesOutput: "packages/shared/src/api/openapi.ts",
|
|
1690
|
-
contractTypesImport: `@${projectName}/shared`,
|
|
1691
|
-
contractPathFromConfig: "../../../contracts/api/openapi.yaml",
|
|
1692
|
-
appRoot: "apps/api/",
|
|
1693
|
-
...renderWorkspacePolicy(packages, detected.length === 0)
|
|
1694
|
-
};
|
|
1695
|
-
}
|
|
2144
|
+
|
|
2145
|
+
// src/materialize/apply.ts
|
|
2146
|
+
import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
|
|
2147
|
+
import path20 from "path";
|
|
2148
|
+
function applyPlan(root, ops) {
|
|
2149
|
+
const written = [];
|
|
2150
|
+
for (const op of ops) {
|
|
2151
|
+
if (op.action === "skip")
|
|
2152
|
+
continue;
|
|
2153
|
+
const absolute = path20.join(root, op.target);
|
|
2154
|
+
mkdirSync(path20.dirname(absolute), { recursive: true });
|
|
2155
|
+
writeFileSync2(absolute, op.content);
|
|
2156
|
+
written.push(op);
|
|
1696
2157
|
}
|
|
1697
|
-
|
|
1698
|
-
var PRESET_IDS = Object.keys(PRESETS);
|
|
1699
|
-
var PRESET_LIST = Object.values(PRESETS);
|
|
1700
|
-
function isPresetId(value) {
|
|
1701
|
-
return value in PRESETS;
|
|
1702
|
-
}
|
|
1703
|
-
function getPreset(id) {
|
|
1704
|
-
return PRESETS[id];
|
|
1705
|
-
}
|
|
1706
|
-
function aiGroups(target) {
|
|
1707
|
-
return target === "both" ? ["ai/shared", "ai/claude", "ai/cursor"] : ["ai/shared", `ai/${target}`];
|
|
1708
|
-
}
|
|
1709
|
-
var DEFAULT_REVIEW_MODEL = "claude-sonnet-5";
|
|
1710
|
-
function reviewGroups(provider) {
|
|
1711
|
-
return provider === "claude" ? ["ai/review"] : [];
|
|
1712
|
-
}
|
|
1713
|
-
function defaultProjectName(dir) {
|
|
1714
|
-
const base = dir.split(/[\\/]/).filter(Boolean).at(-1) ?? "project";
|
|
1715
|
-
return base.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
2158
|
+
return written;
|
|
1716
2159
|
}
|
|
1717
2160
|
|
|
1718
2161
|
// src/ui/prompts.ts
|
|
@@ -1785,24 +2228,6 @@ function createClackPrompter(lore, streams = {}) {
|
|
|
1785
2228
|
};
|
|
1786
2229
|
}
|
|
1787
2230
|
|
|
1788
|
-
// src/version.ts
|
|
1789
|
-
import { readFileSync as readFileSync15 } from "fs";
|
|
1790
|
-
import path19 from "path";
|
|
1791
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1792
|
-
var HERE2 = path19.dirname(fileURLToPath2(import.meta.url));
|
|
1793
|
-
function readVersion() {
|
|
1794
|
-
for (const candidate of ["../package.json", "../../package.json"]) {
|
|
1795
|
-
try {
|
|
1796
|
-
const parsed = JSON.parse(readFileSync15(path19.resolve(HERE2, candidate), "utf8"));
|
|
1797
|
-
if (parsed.name === "mikoshi-construct" && parsed.version != null)
|
|
1798
|
-
return parsed.version;
|
|
1799
|
-
} catch {
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
return "0.0.0";
|
|
1803
|
-
}
|
|
1804
|
-
var VERSION = readVersion();
|
|
1805
|
-
|
|
1806
2231
|
// src/commands/soulkill.ts
|
|
1807
2232
|
function yesNo(value) {
|
|
1808
2233
|
return value ? "yes" : "no";
|
|
@@ -1826,6 +2251,13 @@ function printDetectReport(ui2, report) {
|
|
|
1826
2251
|
|
|
1827
2252
|
// src/commands/init.ts
|
|
1828
2253
|
var CANCELLED = /* @__PURE__ */ Symbol("cancelled");
|
|
2254
|
+
var RESTATED_BY_THE_RUNNING_BINARY = ["constructVersion"];
|
|
2255
|
+
function varsThisRunChanged(previous, vars) {
|
|
2256
|
+
return Object.entries(vars).filter(([name]) => !RESTATED_BY_THE_RUNNING_BINARY.includes(name)).flatMap(([name, to]) => {
|
|
2257
|
+
const from = previous.vars[name];
|
|
2258
|
+
return from == null || from === to ? [] : [{ name, from, to }];
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
1829
2261
|
function aborted(skipped = [], conflicts = []) {
|
|
1830
2262
|
return { status: "aborted", written: [], skipped, conflicts };
|
|
1831
2263
|
}
|
|
@@ -1893,7 +2325,7 @@ async function askChoices(ui2, options, report, root, prompter) {
|
|
|
1893
2325
|
return { presetId, ai, projectName, review };
|
|
1894
2326
|
}
|
|
1895
2327
|
async function runInit(ui2, options, prompter) {
|
|
1896
|
-
const root =
|
|
2328
|
+
const root = path21.resolve(options.dir);
|
|
1897
2329
|
mkdirSync2(root, { recursive: true });
|
|
1898
2330
|
if (!options.yes && prompter == null) {
|
|
1899
2331
|
ui2.glitch(ui2.lore.needsTerminal);
|
|
@@ -1931,7 +2363,7 @@ async function runInit(ui2, options, prompter) {
|
|
|
1931
2363
|
contracts: preset.contracts ? "true" : "false",
|
|
1932
2364
|
contractPath: "contracts/api/openapi.yaml",
|
|
1933
2365
|
contractTypesOutput: "src/contracts/openapi.ts",
|
|
1934
|
-
compositionDir: report.existing.compositionDir ??
|
|
2366
|
+
compositionDir: report.existing.compositionDir ?? DEFAULT_COMPOSITION_DIR,
|
|
1935
2367
|
harnessCommand: "pnpm run quality",
|
|
1936
2368
|
packageManager: "pnpm",
|
|
1937
2369
|
pnpmVersion: report.pnpmVersion ?? "",
|
|
@@ -1957,7 +2389,7 @@ async function runInit(ui2, options, prompter) {
|
|
|
1957
2389
|
ui2.line();
|
|
1958
2390
|
if (plan.omittedGroups.length > 0)
|
|
1959
2391
|
ui2.line(ui2.theme.dim(` ${ui2.lore.sampleOmitted}`));
|
|
1960
|
-
if (report.existing.compositionDir != null && report.existing.compositionDir !==
|
|
2392
|
+
if (report.existing.compositionDir != null && report.existing.compositionDir !== DEFAULT_COMPOSITION_DIR)
|
|
1961
2393
|
ui2.line(ui2.theme.dim(` Existing composition models found at ${report.existing.compositionDir}/ \u2014 kept there, not moved.`));
|
|
1962
2394
|
if (plan.conflicts.length > 0)
|
|
1963
2395
|
ui2.glitch("Existing values kept; review these keys by hand:", plan.conflicts);
|
|
@@ -1970,7 +2402,17 @@ async function runInit(ui2, options, prompter) {
|
|
|
1970
2402
|
if (interactive != null && await interactive.confirm(ui2.lore.confirm) !== true)
|
|
1971
2403
|
return aborted(skipped, plan.conflicts);
|
|
1972
2404
|
const written = applyPlan(root, plan.ops);
|
|
1973
|
-
|
|
2405
|
+
const previous = readManifest(root);
|
|
2406
|
+
const manifest = buildManifest({ version: VERSION, preset: presetId, ai, review, vars, written, contracts: preset.contracts, previous });
|
|
2407
|
+
writeManifest(root, manifest);
|
|
2408
|
+
if (previous != null) {
|
|
2409
|
+
const carriedOver = Object.keys(previous.files).filter((target) => !written.some((op) => op.target === target)).length;
|
|
2410
|
+
const added = written.filter((op) => previous.files[op.target] == null).length;
|
|
2411
|
+
ui2.line(ui2.theme.dim(` ${ui2.lore.recordCarriedOver(carriedOver, added)}`));
|
|
2412
|
+
const changed = varsThisRunChanged(previous, vars);
|
|
2413
|
+
if (changed.length > 0)
|
|
2414
|
+
ui2.line(ui2.theme.dim(` ${ui2.lore.recordVarsChanged(changed)}`));
|
|
2415
|
+
}
|
|
1974
2416
|
ui2.phase(4, 4, "\u2705", ui2.lore.phaseOnline);
|
|
1975
2417
|
ui2.tree([
|
|
1976
2418
|
["Written", `${written.length} files`],
|
|
@@ -1980,10 +2422,202 @@ async function runInit(ui2, options, prompter) {
|
|
|
1980
2422
|
return { status: "done", written: written.map((op) => op.target), skipped, conflicts: plan.conflicts };
|
|
1981
2423
|
}
|
|
1982
2424
|
|
|
2425
|
+
// src/commands/sync/report.ts
|
|
2426
|
+
var SYNC_EXIT = {
|
|
2427
|
+
upToDate: 0,
|
|
2428
|
+
noManifest: 1,
|
|
2429
|
+
pending: 2
|
|
2430
|
+
};
|
|
2431
|
+
var SYNC_APPLY_EXIT = {
|
|
2432
|
+
written: 0,
|
|
2433
|
+
noManifest: 1,
|
|
2434
|
+
refused: 2
|
|
2435
|
+
};
|
|
2436
|
+
var LISTED_CLASSES = ["add", "update", "conflict", "unknown", "removed", "orphaned"];
|
|
2437
|
+
var CLASS_COLUMN = Math.max(...PATH_CLASSES.map((value) => value.length)) + 2;
|
|
2438
|
+
function pendingCount(report) {
|
|
2439
|
+
return PENDING_CLASSES.reduce((total, value) => total + report.counts[value], 0);
|
|
2440
|
+
}
|
|
2441
|
+
function syncExit(report) {
|
|
2442
|
+
if (report == null)
|
|
2443
|
+
return SYNC_EXIT.noManifest;
|
|
2444
|
+
return pendingCount(report) === 0 ? SYNC_EXIT.upToDate : SYNC_EXIT.pending;
|
|
2445
|
+
}
|
|
2446
|
+
function actionableKeys(entry) {
|
|
2447
|
+
return entry.keys.filter((key) => key.class !== "keep").map((key) => `${key.key} (${key.class})`);
|
|
2448
|
+
}
|
|
2449
|
+
function syncJson(report) {
|
|
2450
|
+
return {
|
|
2451
|
+
fromVersion: report.fromVersion,
|
|
2452
|
+
toVersion: report.toVersion,
|
|
2453
|
+
counts: report.counts,
|
|
2454
|
+
paths: report.classifications.map((entry) => ({
|
|
2455
|
+
target: entry.target,
|
|
2456
|
+
class: entry.class,
|
|
2457
|
+
strategy: entry.strategy,
|
|
2458
|
+
...entry.keys.length === 0 ? {} : { keys: entry.keys },
|
|
2459
|
+
...entry.variant == null ? {} : { variant: entry.variant.variant, variantEvidence: entry.variant.evidence },
|
|
2460
|
+
...entry.shape == null ? {} : { shape: entry.shape },
|
|
2461
|
+
...entry.writeEffect == null ? {} : { writeEffect: entry.writeEffect }
|
|
2462
|
+
}))
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
function note(ui2, entry) {
|
|
2466
|
+
if (entry.strategy === "merge-json") {
|
|
2467
|
+
const keys = actionableKeys(entry);
|
|
2468
|
+
return keys.length === 0 ? "" : ui2.lore.syncMergedKeys(keys);
|
|
2469
|
+
}
|
|
2470
|
+
if (entry.class === "unknown")
|
|
2471
|
+
return ui2.lore.syncVariantUnknown(entry.shape ?? "");
|
|
2472
|
+
return entry.writeEffect == null ? "" : ui2.lore.syncWriteEffect[entry.writeEffect] ?? "";
|
|
2473
|
+
}
|
|
2474
|
+
var COUNT_ORDER = ["add", "update", "conflict", "unknown", "removed", "orphaned", "keep", "foreign"];
|
|
2475
|
+
function printCounts(ui2, report) {
|
|
2476
|
+
ui2.line(ui2.theme.accent(ui2.lore.syncClasses));
|
|
2477
|
+
for (const value of COUNT_ORDER) {
|
|
2478
|
+
if (report.counts[value] === 0)
|
|
2479
|
+
continue;
|
|
2480
|
+
const meaning = ui2.lore.syncClassMeaning[value] ?? "";
|
|
2481
|
+
ui2.line(` ${value.padEnd(CLASS_COLUMN)}${String(report.counts[value]).padStart(3)} ${ui2.theme.dim(meaning)}`);
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
function printPaths(ui2, report) {
|
|
2485
|
+
for (const value of LISTED_CLASSES) {
|
|
2486
|
+
const entries = report.classifications.filter((entry) => entry.class === value);
|
|
2487
|
+
if (entries.length === 0)
|
|
2488
|
+
continue;
|
|
2489
|
+
ui2.line();
|
|
2490
|
+
ui2.line(`${ui2.theme.accent(value)} ${ui2.theme.dim(`\u2014 ${ui2.lore.syncClassMeaning[value] ?? ""}`)}`);
|
|
2491
|
+
for (const entry of entries) {
|
|
2492
|
+
const detail = note(ui2, entry);
|
|
2493
|
+
ui2.line(` ${entry.target}${detail === "" ? "" : ui2.theme.dim(` \u2014 ${detail}`)}`);
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
}
|
|
2497
|
+
function printMergedNote(ui2, report) {
|
|
2498
|
+
const listed = report.classifications.filter((entry) => LISTED_CLASSES.includes(entry.class));
|
|
2499
|
+
if (!listed.some((entry) => entry.strategy === "merge-json"))
|
|
2500
|
+
return;
|
|
2501
|
+
ui2.line();
|
|
2502
|
+
ui2.line(ui2.theme.dim(ui2.lore.syncMergedNotWritten));
|
|
2503
|
+
}
|
|
2504
|
+
function printSync(ui2, report) {
|
|
2505
|
+
if (report == null) {
|
|
2506
|
+
ui2.flatline(ui2.lore.syncNoManifest);
|
|
2507
|
+
return SYNC_EXIT.noManifest;
|
|
2508
|
+
}
|
|
2509
|
+
ui2.line(ui2.theme.accent(ui2.theme.bold(ui2.lore.syncTitle)));
|
|
2510
|
+
ui2.line(ui2.theme.bold(ui2.lore.syncVersionGap(report.fromVersion, report.toVersion)));
|
|
2511
|
+
ui2.line();
|
|
2512
|
+
printCounts(ui2, report);
|
|
2513
|
+
printPaths(ui2, report);
|
|
2514
|
+
printMergedNote(ui2, report);
|
|
2515
|
+
const pending = pendingCount(report);
|
|
2516
|
+
ui2.line();
|
|
2517
|
+
ui2.line(pending === 0 ? ui2.lore.syncNothingToWrite : ui2.lore.syncPending(pending));
|
|
2518
|
+
return syncExit(report);
|
|
2519
|
+
}
|
|
2520
|
+
function syncApplyExit(result) {
|
|
2521
|
+
if (result == null)
|
|
2522
|
+
return SYNC_APPLY_EXIT.noManifest;
|
|
2523
|
+
return result.refused.length === 0 ? SYNC_APPLY_EXIT.written : SYNC_APPLY_EXIT.refused;
|
|
2524
|
+
}
|
|
2525
|
+
function syncApplyJson(result) {
|
|
2526
|
+
return {
|
|
2527
|
+
...syncJson(result.report),
|
|
2528
|
+
written: result.written,
|
|
2529
|
+
pending: result.refused.map((entry) => entry.target),
|
|
2530
|
+
ranAt: result.ranAt
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
function printWritten(ui2, result) {
|
|
2534
|
+
if (result.written.length === 0)
|
|
2535
|
+
return;
|
|
2536
|
+
const byTarget = new Map(result.report.classifications.map((entry) => [entry.target, entry]));
|
|
2537
|
+
ui2.line();
|
|
2538
|
+
ui2.line(ui2.theme.accent(ui2.lore.syncApplyWritten));
|
|
2539
|
+
for (const target of result.written) {
|
|
2540
|
+
const entry = byTarget.get(target);
|
|
2541
|
+
const detail = entry == null ? "" : note(ui2, entry);
|
|
2542
|
+
ui2.line(` ${target}${detail === "" ? "" : ui2.theme.dim(` \u2014 ${detail}`)}`);
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
function printUnknownVariants(ui2, result) {
|
|
2546
|
+
const unknown = result.report.classifications.filter((entry) => entry.class === "unknown");
|
|
2547
|
+
if (unknown.length === 0)
|
|
2548
|
+
return;
|
|
2549
|
+
ui2.line();
|
|
2550
|
+
ui2.line(ui2.theme.accent(ui2.lore.syncApplyUnknown));
|
|
2551
|
+
for (const entry of unknown)
|
|
2552
|
+
ui2.line(` ${entry.target}${ui2.theme.dim(` \u2014 ${note(ui2, entry)}`)}`);
|
|
2553
|
+
}
|
|
2554
|
+
function printRefused(ui2, result) {
|
|
2555
|
+
if (result.refused.length === 0)
|
|
2556
|
+
return;
|
|
2557
|
+
ui2.line();
|
|
2558
|
+
ui2.line(ui2.theme.accent(ui2.lore.syncApplyRefused));
|
|
2559
|
+
for (const entry of result.refused) {
|
|
2560
|
+
const detail = note(ui2, entry);
|
|
2561
|
+
ui2.line(` ${entry.target}${detail === "" ? "" : ui2.theme.dim(` \u2014 ${detail}`)}`);
|
|
2562
|
+
}
|
|
2563
|
+
ui2.line(ui2.theme.dim(ui2.lore.syncMergedNotWritten));
|
|
2564
|
+
}
|
|
2565
|
+
function printSyncApply(ui2, result) {
|
|
2566
|
+
if (result == null) {
|
|
2567
|
+
ui2.flatline(ui2.lore.syncNoManifest);
|
|
2568
|
+
return SYNC_APPLY_EXIT.noManifest;
|
|
2569
|
+
}
|
|
2570
|
+
ui2.line(ui2.theme.accent(ui2.theme.bold(ui2.lore.syncApplyTitle)));
|
|
2571
|
+
ui2.line(ui2.theme.bold(ui2.lore.syncVersionGap(result.report.fromVersion, result.report.toVersion)));
|
|
2572
|
+
printWritten(ui2, result);
|
|
2573
|
+
printUnknownVariants(ui2, result);
|
|
2574
|
+
printRefused(ui2, result);
|
|
2575
|
+
ui2.line();
|
|
2576
|
+
ui2.line(result.written.length === 0 ? ui2.lore.syncApplyNothingWritten : ui2.lore.syncApplyWrote(result.written.length));
|
|
2577
|
+
if (result.refused.length > 0)
|
|
2578
|
+
ui2.line(ui2.lore.syncApplyLeftToYou(result.refused.length));
|
|
2579
|
+
return syncApplyExit(result);
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
// src/commands/sync/index.ts
|
|
2583
|
+
function countByClass(classifications) {
|
|
2584
|
+
const counts = Object.fromEntries(PATH_CLASSES.map((value) => [value, 0]));
|
|
2585
|
+
for (const entry of classifications)
|
|
2586
|
+
counts[entry.class] += 1;
|
|
2587
|
+
return counts;
|
|
2588
|
+
}
|
|
2589
|
+
function runSync(root, version) {
|
|
2590
|
+
const manifest = readManifest(root);
|
|
2591
|
+
if (manifest == null)
|
|
2592
|
+
return null;
|
|
2593
|
+
const { fromVersion, toVersion, classifications } = replay({ root, manifest, version, facts: factsTheRepositoryEstablishes(root) });
|
|
2594
|
+
return { fromVersion, toVersion, counts: countByClass(classifications), classifications };
|
|
2595
|
+
}
|
|
2596
|
+
function applySync(root, version) {
|
|
2597
|
+
const manifest = readManifest(root);
|
|
2598
|
+
if (manifest == null)
|
|
2599
|
+
return null;
|
|
2600
|
+
const { fromVersion, toVersion, present, produced, classifications } = replay({ root, manifest, version, facts: factsTheRepositoryEstablishes(root) });
|
|
2601
|
+
const report = { fromVersion, toVersion, counts: countByClass(classifications), classifications };
|
|
2602
|
+
const { writes, refused } = planWrites({ classifications, present, produced });
|
|
2603
|
+
const written = applyPlan(root, writes.map((write) => ({ target: write.target, strategy: write.strategy, action: "create", content: write.content })));
|
|
2604
|
+
const ranAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2605
|
+
if (writes.length > 0) {
|
|
2606
|
+
writeManifest(root, recordSync(manifest, {
|
|
2607
|
+
ranAt,
|
|
2608
|
+
toVersion: version,
|
|
2609
|
+
files: Object.fromEntries(writes.map((write) => [write.target, write.ownedSha])),
|
|
2610
|
+
variants: Object.fromEntries(writes.flatMap((write) => write.variant == null ? [] : [[write.target, write.variant]]))
|
|
2611
|
+
}));
|
|
2612
|
+
}
|
|
2613
|
+
return { report, written: written.map((op) => op.target), refused, ranAt };
|
|
2614
|
+
}
|
|
2615
|
+
|
|
1983
2616
|
// src/ui/console.ts
|
|
1984
2617
|
import process4 from "process";
|
|
1985
2618
|
|
|
1986
2619
|
// src/ui/lore.ts
|
|
2620
|
+
var BLOCK_REPLACED_WHOLE = "block-replaced-whole-discovery-bodies-carried-over";
|
|
1987
2621
|
var BANNER = String.raw`
|
|
1988
2622
|
███╗ ███╗██╗██╗ ██╗ ██████╗ ███████╗██╗ ██╗██╗
|
|
1989
2623
|
████╗ ████║██║██║ ██╔╝██╔═══██╗██╔════╝██║ ██║██║
|
|
@@ -2021,6 +2655,9 @@ var LORE = {
|
|
|
2021
2655
|
discoveryIncomplete: "Discovery incomplete.",
|
|
2022
2656
|
provenance: "AUTHORSHIP TRACE",
|
|
2023
2657
|
stillConstructAuthored: (count) => `Still the construct's own words: ${count} marker${count === 1 ? "" : "s"} nobody has stood behind yet.`,
|
|
2658
|
+
baselineCurrent: "The baseline reads back what today's templates produce.",
|
|
2659
|
+
baselineMoved: (count) => `THE BASELINE MOVED ON: ${count} recorded path${count === 1 ? "" : "s"} a sync would add or update \u2014 run \`construct sync\`.`,
|
|
2660
|
+
baselineGapUnknown: "What a sync would add or update cannot be established from this manifest: run `construct sync`.",
|
|
2024
2661
|
enforcement: "ENFORCEMENT TRACE",
|
|
2025
2662
|
typecheckCaveat: "Typecheck cannot carry this stack alone.",
|
|
2026
2663
|
weakestLink: (id, level) => `WEAKEST LINK: ${id} at ${level}`,
|
|
@@ -2046,7 +2683,38 @@ var LORE = {
|
|
|
2046
2683
|
ledgerMalformed: (count) => `${count} ledger line${count === 1 ? "" : "s"} could not be read as a run record.`,
|
|
2047
2684
|
ledgerDrift: (entriesWithoutSession, sessionsWithoutEntry, unjoinable) => `Ledger against the traces it claims: ${entriesWithoutSession} entries with no session, ${sessionsWithoutEntry} sessions with no entry, ${unjoinable} entries with no run id.`,
|
|
2048
2685
|
ledgerEntryWithoutSession: "logged as a run, no session behind it",
|
|
2049
|
-
ledgerSessionWithoutEntry: "ran, never logged"
|
|
2686
|
+
ledgerSessionWithoutEntry: "ran, never logged",
|
|
2687
|
+
syncTitle: "BRAINDANCE \u2014 ENGRAM REPLAY",
|
|
2688
|
+
syncClasses: "PATH CLASSES",
|
|
2689
|
+
syncClassMeaning: {
|
|
2690
|
+
add: "not in the tree; the templates produce it",
|
|
2691
|
+
update: "the construct owns this and the template moved on",
|
|
2692
|
+
conflict: "yours \u2014 you wrote or changed it; sync never touches these",
|
|
2693
|
+
unknown: "which template variant wrote this block cannot be established; you changed nothing, and sync writes nothing here",
|
|
2694
|
+
removed: "you deleted it; sync never puts it back",
|
|
2695
|
+
orphaned: "the construct wrote it once and no longer produces it; it is yours now",
|
|
2696
|
+
keep: "already what the templates produce",
|
|
2697
|
+
foreign: "never ours"
|
|
2698
|
+
},
|
|
2699
|
+
syncMergedKeys: (keys) => `keys: ${keys.join(", ")}`,
|
|
2700
|
+
syncMergedNotWritten: "A merged target is reported by its keys and never rewritten: no merge-json file is written in this version.",
|
|
2701
|
+
syncWriteEffect: {
|
|
2702
|
+
[BLOCK_REPLACED_WHOLE]: "the construct block is replaced whole \u2014 edits between the delimiters do not survive; the discovery marker bodies are carried over"
|
|
2703
|
+
},
|
|
2704
|
+
syncVariantUnknown: (shape) => `no record of the variant that wrote it and no rendering matches the recorded hash; the shape reads like the ${shape} variant, which is a guess and never enough to write on`,
|
|
2705
|
+
syncApplyUnknown: "BEYOND THE BLACKWALL",
|
|
2706
|
+
syncNothingToWrite: "NOTHING TO WRITE \u2014 the replay reads back what the tree already carries.",
|
|
2707
|
+
syncPending: (count) => `${count} path${count === 1 ? "" : "s"} can be written: run \`construct sync --apply\`.`,
|
|
2708
|
+
syncApplyTitle: "RELIC WRITE",
|
|
2709
|
+
syncApplyWritten: "WRITTEN",
|
|
2710
|
+
syncApplyRefused: "LEFT TO YOU",
|
|
2711
|
+
syncApplyWrote: (count) => `${count} path${count === 1 ? "" : "s"} written. The manifest records the owned view of each of them.`,
|
|
2712
|
+
syncApplyNothingWritten: "NOTHING WRITTEN \u2014 the tree already carries what the construct owns.",
|
|
2713
|
+
syncApplyLeftToYou: (count) => `${count} path${count === 1 ? "" : "s"} the record cannot prove the construct owns. Yours to carry across.`,
|
|
2714
|
+
syncVersionGap: (from, to) => `ENGRAM CUT BY v${from} // REPLAYED BY v${to}`,
|
|
2715
|
+
syncNoManifest: "No construct.json here. Run `construct init` first.",
|
|
2716
|
+
recordCarriedOver: (carried, added) => `ENGRAM EXTENDED: ${carried} record${carried === 1 ? "" : "s"} carried over from the construct.json already here, ${added} added.`,
|
|
2717
|
+
recordVarsChanged: (changed) => `ENGRAM REWRITTEN: this run changed ${changed.map((entry) => `${entry.name} (${entry.from} \u2192 ${entry.to})`).join(", ")} in the record; the recorded hashes were taken with the old value${changed.length === 1 ? "" : "s"}.`
|
|
2050
2718
|
};
|
|
2051
2719
|
var PLAIN_LORE = {
|
|
2052
2720
|
subtitle: (version) => `mikoshi-construct v${version}`,
|
|
@@ -2078,6 +2746,9 @@ var PLAIN_LORE = {
|
|
|
2078
2746
|
discoveryIncomplete: "Discovery incomplete.",
|
|
2079
2747
|
provenance: "Discovery provenance",
|
|
2080
2748
|
stillConstructAuthored: (count) => `Unchanged since discovery wrote them: ${count} marker${count === 1 ? "" : "s"} nobody has stood behind yet.`,
|
|
2749
|
+
baselineCurrent: "The baseline reads back what today's templates produce.",
|
|
2750
|
+
baselineMoved: (count) => `The baseline moved on: ${count} recorded path${count === 1 ? "" : "s"} a sync would add or update \u2014 run \`construct sync\`.`,
|
|
2751
|
+
baselineGapUnknown: "What a sync would add or update cannot be established from this manifest: run `construct sync`.",
|
|
2081
2752
|
enforcement: "Enforcement",
|
|
2082
2753
|
typecheckCaveat: "Typecheck cannot carry this stack alone.",
|
|
2083
2754
|
weakestLink: (id, level) => `Weakest link: ${id} at ${level}`,
|
|
@@ -2103,7 +2774,38 @@ var PLAIN_LORE = {
|
|
|
2103
2774
|
ledgerMalformed: (count) => `${count} ledger line${count === 1 ? "" : "s"} could not be read as a run record.`,
|
|
2104
2775
|
ledgerDrift: (entriesWithoutSession, sessionsWithoutEntry, unjoinable) => `Ledger against the runtime: ${entriesWithoutSession} entries with no session, ${sessionsWithoutEntry} sessions with no entry, ${unjoinable} entries with no run id.`,
|
|
2105
2776
|
ledgerEntryWithoutSession: "logged as a run, no session behind it",
|
|
2106
|
-
ledgerSessionWithoutEntry: "ran, never logged"
|
|
2777
|
+
ledgerSessionWithoutEntry: "ran, never logged",
|
|
2778
|
+
syncTitle: "Sync report",
|
|
2779
|
+
syncClasses: "Classes",
|
|
2780
|
+
syncClassMeaning: {
|
|
2781
|
+
add: "not in the tree; the templates produce it",
|
|
2782
|
+
update: "the construct owns this and the template moved on",
|
|
2783
|
+
conflict: "yours \u2014 you wrote or changed it; sync never touches these",
|
|
2784
|
+
unknown: "which template variant wrote this block cannot be established; you changed nothing, and sync writes nothing here",
|
|
2785
|
+
removed: "you deleted it; sync never puts it back",
|
|
2786
|
+
orphaned: "the construct wrote it once and no longer produces it; it is yours now",
|
|
2787
|
+
keep: "already what the templates produce",
|
|
2788
|
+
foreign: "never ours"
|
|
2789
|
+
},
|
|
2790
|
+
syncMergedKeys: (keys) => `keys: ${keys.join(", ")}`,
|
|
2791
|
+
syncMergedNotWritten: "A merged target is reported by its keys and never rewritten: no merge-json file is written in this version.",
|
|
2792
|
+
syncWriteEffect: {
|
|
2793
|
+
[BLOCK_REPLACED_WHOLE]: "the construct block is replaced whole \u2014 edits between the delimiters do not survive; the discovery marker bodies are carried over"
|
|
2794
|
+
},
|
|
2795
|
+
syncVariantUnknown: (shape) => `no record of the variant that wrote it and no rendering matches the recorded hash; the shape reads like the ${shape} variant, which is a guess and never enough to write on`,
|
|
2796
|
+
syncApplyUnknown: "Variant unknown",
|
|
2797
|
+
syncNothingToWrite: "Nothing to write: the replay reads back what the tree already carries.",
|
|
2798
|
+
syncPending: (count) => `${count} path${count === 1 ? "" : "s"} can be written: run \`construct sync --apply\`.`,
|
|
2799
|
+
syncApplyTitle: "Sync apply",
|
|
2800
|
+
syncApplyWritten: "Written",
|
|
2801
|
+
syncApplyRefused: "Left to you",
|
|
2802
|
+
syncApplyWrote: (count) => `${count} path${count === 1 ? "" : "s"} written. The manifest records the owned view of each of them.`,
|
|
2803
|
+
syncApplyNothingWritten: "Nothing written: the tree already carries what the construct owns.",
|
|
2804
|
+
syncApplyLeftToYou: (count) => `${count} path${count === 1 ? "" : "s"} the record cannot prove the construct owns. Yours to carry across.`,
|
|
2805
|
+
syncVersionGap: (from, to) => `Materialized by construct ${from}, read by ${to}.`,
|
|
2806
|
+
syncNoManifest: "No construct.json here. Run `construct init` first.",
|
|
2807
|
+
recordCarriedOver: (carried, added) => `Carried over ${carried} record${carried === 1 ? "" : "s"} from the construct.json already here; added ${added}.`,
|
|
2808
|
+
recordVarsChanged: (changed) => `This run changed ${changed.map((entry) => `${entry.name} (${entry.from} -> ${entry.to})`).join(", ")} in the record; the recorded hashes were taken with the old value${changed.length === 1 ? "" : "s"}.`
|
|
2107
2809
|
};
|
|
2108
2810
|
|
|
2109
2811
|
// src/ui/console.ts
|
|
@@ -2305,7 +3007,7 @@ var cost = defineCommand({
|
|
|
2305
3007
|
json: { type: "boolean", description: "Machine-readable report", default: false }
|
|
2306
3008
|
},
|
|
2307
3009
|
run({ args }) {
|
|
2308
|
-
const report = costReport(
|
|
3010
|
+
const report = costReport(path22.resolve(args.dir));
|
|
2309
3011
|
if (args.json) {
|
|
2310
3012
|
process6.stdout.write(`${JSON.stringify(costJson(report, args.last), null, 2)}
|
|
2311
3013
|
`);
|
|
@@ -2315,6 +3017,41 @@ var cost = defineCommand({
|
|
|
2315
3017
|
process6.exitCode = printCost(ui(args), report, args.last);
|
|
2316
3018
|
}
|
|
2317
3019
|
});
|
|
3020
|
+
var sync = defineCommand({
|
|
3021
|
+
meta: { name: "sync", description: "Classify what today's construct would change in this repository; --apply writes what it owns" },
|
|
3022
|
+
args: {
|
|
3023
|
+
...commonArgs,
|
|
3024
|
+
json: { type: "boolean", description: "Machine-readable report", default: false },
|
|
3025
|
+
apply: { type: "boolean", description: "Write the paths the construct owns \u2014 the only way sync writes; never a conflict, a removal or a merged file", default: false }
|
|
3026
|
+
},
|
|
3027
|
+
run({ args }) {
|
|
3028
|
+
const console = ui(args);
|
|
3029
|
+
if (args.apply) {
|
|
3030
|
+
try {
|
|
3031
|
+
const result = applySync(args.dir, VERSION);
|
|
3032
|
+
if (args.json) {
|
|
3033
|
+
process6.stdout.write(`${JSON.stringify(result == null ? null : syncApplyJson(result), null, 2)}
|
|
3034
|
+
`);
|
|
3035
|
+
process6.exitCode = syncApplyExit(result);
|
|
3036
|
+
return;
|
|
3037
|
+
}
|
|
3038
|
+
process6.exitCode = printSyncApply(console, result);
|
|
3039
|
+
} catch (error) {
|
|
3040
|
+
console.flatline(error instanceof Error ? error.message : String(error));
|
|
3041
|
+
process6.exitCode = 1;
|
|
3042
|
+
}
|
|
3043
|
+
return;
|
|
3044
|
+
}
|
|
3045
|
+
const report = runSync(args.dir, VERSION);
|
|
3046
|
+
if (args.json) {
|
|
3047
|
+
process6.stdout.write(`${JSON.stringify(report == null ? null : syncJson(report), null, 2)}
|
|
3048
|
+
`);
|
|
3049
|
+
process6.exitCode = syncExit(report);
|
|
3050
|
+
return;
|
|
3051
|
+
}
|
|
3052
|
+
process6.exitCode = printSync(console, report);
|
|
3053
|
+
}
|
|
3054
|
+
});
|
|
2318
3055
|
var main = defineCommand({
|
|
2319
3056
|
meta: {
|
|
2320
3057
|
name: "construct",
|
|
@@ -2327,6 +3064,7 @@ var main = defineCommand({
|
|
|
2327
3064
|
inspect: soulkill,
|
|
2328
3065
|
capture: soulkill,
|
|
2329
3066
|
doctor,
|
|
3067
|
+
sync,
|
|
2330
3068
|
cost
|
|
2331
3069
|
}
|
|
2332
3070
|
});
|