@milehimikey/em 1.17.0 → 1.18.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/.claude/agents/em-implementer.md +7 -4
- package/.claude/agents/em-validator.md +2 -2
- package/.claude/skills/event-modeling/SKILL.md +1 -1
- package/.claude/skills/event-modeling-conform/SKILL.md +1 -1
- package/.claude/skills/event-modeling-design/SKILL.md +46 -5
- package/.claude/skills/event-modeling-discover/SKILL.md +43 -8
- package/.claude/skills/event-modeling-discover/reference/extract.md +6 -1
- package/.claude/skills/event-modeling-discover/reference/prd-rubric.md +102 -0
- package/.claude/skills/event-modeling-discover/reference/prd-spec-path.md +171 -0
- package/.claude/skills/event-modeling-engagement/SKILL.md +21 -9
- package/.claude/skills/event-modeling-implement/SKILL.md +1 -1
- package/.claude/skills/event-modeling-implement/reference/implement.md +18 -0
- package/.claude/skills/event-modeling-review/SKILL.md +4 -2
- package/.claude/skills/event-modeling-shared/reference/em-dsl.md +94 -6
- package/.claude/skills/event-modeling-shared/reference/methodology.md +268 -0
- package/.claude/skills/event-modeling-shared/reference/slice-doc-schema.md +96 -2
- package/.claude/skills/event-modeling-shared/templates/slice.md +11 -3
- package/.claude-plugin/marketplace.json +2 -2
- package/dist/catalog/docModelConsistencyValidate.js +160 -0
- package/dist/catalog/docModelConsistencyValidate.js.map +1 -1
- package/dist/catalog/invariantsValidate.d.ts +2 -0
- package/dist/catalog/invariantsValidate.js +67 -1
- package/dist/catalog/invariantsValidate.js.map +1 -1
- package/dist/catalog/scenarioQualityValidate.d.ts +4 -0
- package/dist/catalog/scenarioQualityValidate.js +148 -0
- package/dist/catalog/scenarioQualityValidate.js.map +1 -0
- package/dist/catalog/scenarios.d.ts +5 -0
- package/dist/catalog/scenarios.js +80 -0
- package/dist/catalog/scenarios.js.map +1 -1
- package/dist/catalog/sliceDoc.d.ts +4 -0
- package/dist/catalog/sliceDoc.js +16 -7
- package/dist/catalog/sliceDoc.js.map +1 -1
- package/dist/catalog/sliceReadyValidate.d.ts +11 -2
- package/dist/catalog/sliceReadyValidate.js +62 -3
- package/dist/catalog/sliceReadyValidate.js.map +1 -1
- package/dist/catalog/sliceSections.d.ts +3 -2
- package/dist/catalog/sliceSections.js +99 -9
- package/dist/catalog/sliceSections.js.map +1 -1
- package/dist/cli/ciInit.d.ts +4 -11
- package/dist/cli/ciInit.js +24 -6
- package/dist/cli/ciInit.js.map +1 -1
- package/dist/cli/coverage.d.ts +9 -0
- package/dist/cli/coverage.js +18 -0
- package/dist/cli/coverage.js.map +1 -1
- package/dist/cli/defer.d.ts +28 -0
- package/dist/cli/defer.js +87 -4
- package/dist/cli/defer.js.map +1 -1
- package/dist/cli/engagement.d.ts +13 -2
- package/dist/cli/engagement.js +60 -5
- package/dist/cli/engagement.js.map +1 -1
- package/dist/cli/engagementFile.d.ts +2 -0
- package/dist/cli/engagementFile.js +3 -1
- package/dist/cli/engagementFile.js.map +1 -1
- package/dist/cli/markImplemented.d.ts +14 -10
- package/dist/cli/markImplemented.js +49 -1
- package/dist/cli/markImplemented.js.map +1 -1
- package/dist/cli/prdTriage.d.ts +109 -0
- package/dist/cli/prdTriage.js +393 -0
- package/dist/cli/prdTriage.js.map +1 -0
- package/dist/cli/render-inputs.d.ts +2 -0
- package/dist/cli/render-inputs.js +4 -0
- package/dist/cli/render-inputs.js.map +1 -1
- package/dist/cli/review.d.ts +17 -2
- package/dist/cli/review.js +39 -10
- package/dist/cli/review.js.map +1 -1
- package/dist/cli/revise.d.ts +2 -0
- package/dist/cli/revise.js +7 -2
- package/dist/cli/revise.js.map +1 -1
- package/dist/cli/shape.d.ts +27 -0
- package/dist/cli/shape.js +77 -0
- package/dist/cli/shape.js.map +1 -0
- package/dist/cli/sliceIndex.d.ts +6 -1
- package/dist/cli/sliceIndex.js +28 -13
- package/dist/cli/sliceIndex.js.map +1 -1
- package/dist/cli/sliceNew.d.ts +4 -1
- package/dist/cli/sliceNew.js +26 -9
- package/dist/cli/sliceNew.js.map +1 -1
- package/dist/cli/status.d.ts +21 -1
- package/dist/cli/status.js +25 -3
- package/dist/cli/status.js.map +1 -1
- package/dist/cli/storylineSuggest.d.ts +26 -0
- package/dist/cli/storylineSuggest.js +252 -0
- package/dist/cli/storylineSuggest.js.map +1 -0
- package/dist/cli/upgrade.d.ts +15 -3
- package/dist/cli/upgrade.js +516 -7
- package/dist/cli/upgrade.js.map +1 -1
- package/dist/cli/upgradeReshape.d.ts +60 -0
- package/dist/cli/upgradeReshape.js +425 -0
- package/dist/cli/upgradeReshape.js.map +1 -0
- package/dist/cli.js +311 -35
- package/dist/cli.js.map +1 -1
- package/dist/emit/dot.d.ts +2 -0
- package/dist/emit/dot.js +44 -8
- package/dist/emit/dot.js.map +1 -1
- package/dist/emit/engagementJson.d.ts +4 -3
- package/dist/emit/engagementJson.js +11 -4
- package/dist/emit/engagementJson.js.map +1 -1
- package/dist/emit/json.d.ts +21 -1
- package/dist/emit/json.js +26 -2
- package/dist/emit/json.js.map +1 -1
- package/dist/emit/queryJson.d.ts +1 -1
- package/dist/emit/queryJson.js +3 -1
- package/dist/emit/queryJson.js.map +1 -1
- package/dist/emit/statusJson.d.ts +1 -1
- package/dist/emit/statusJson.js +6 -1
- package/dist/emit/statusJson.js.map +1 -1
- package/dist/emit/theme.d.ts +1 -0
- package/dist/emit/theme.js +13 -0
- package/dist/emit/theme.js.map +1 -1
- package/dist/emit/validateJson.d.ts +9 -5
- package/dist/emit/validateJson.js +40 -5
- package/dist/emit/validateJson.js.map +1 -1
- package/dist/layout/grid.d.ts +5 -1
- package/dist/layout/grid.js +93 -2
- package/dist/layout/grid.js.map +1 -1
- package/dist/mcp/server.js +46 -11
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/edges.d.ts +7 -0
- package/dist/model/edges.js +33 -16
- package/dist/model/edges.js.map +1 -1
- package/dist/model/model.d.ts +33 -0
- package/dist/model/model.js +87 -3
- package/dist/model/model.js.map +1 -1
- package/dist/model/queryIndex.js +2 -2
- package/dist/model/queryIndex.js.map +1 -1
- package/dist/model/refs.js +8 -5
- package/dist/model/refs.js.map +1 -1
- package/dist/model/rules.d.ts +149 -0
- package/dist/model/rules.js +144 -0
- package/dist/model/rules.js.map +1 -1
- package/dist/model/shapeLint.d.ts +12 -0
- package/dist/model/shapeLint.js +170 -0
- package/dist/model/shapeLint.js.map +1 -0
- package/dist/model/storylines.d.ts +51 -0
- package/dist/model/storylines.js +188 -0
- package/dist/model/storylines.js.map +1 -0
- package/dist/model/validate.d.ts +6 -1
- package/dist/model/validate.js +317 -14
- package/dist/model/validate.js.map +1 -1
- package/dist/parser/againSugar.d.ts +30 -0
- package/dist/parser/againSugar.js +105 -0
- package/dist/parser/againSugar.js.map +1 -0
- package/dist/parser/ast.d.ts +33 -0
- package/dist/parser/lexer.d.ts +2 -0
- package/dist/parser/lexer.js +4 -0
- package/dist/parser/lexer.js.map +1 -1
- package/dist/parser/parser.js +284 -4
- package/dist/parser/parser.js.map +1 -1
- package/dist/query/format.js +1 -0
- package/dist/query/format.js.map +1 -1
- package/dist/query/verbs.d.ts +4 -0
- package/dist/query/verbs.js +30 -3
- package/dist/query/verbs.js.map +1 -1
- package/dist/render/drawEdges.js +100 -4
- package/dist/render/drawEdges.js.map +1 -1
- package/dist/render/render.js +20 -2
- package/dist/render/render.js.map +1 -1
- package/dist/render/sliceDiagram.js +5 -5
- package/dist/render/sliceDiagram.js.map +1 -1
- package/dist/render/storylineBands.d.ts +11 -0
- package/dist/render/storylineBands.js +80 -0
- package/dist/render/storylineBands.js.map +1 -0
- package/dist/render/storylineDiagram.d.ts +14 -0
- package/dist/render/storylineDiagram.js +142 -0
- package/dist/render/storylineDiagram.js.map +1 -0
- package/dist/roster/check.d.ts +32 -0
- package/dist/roster/check.js +92 -0
- package/dist/roster/check.js.map +1 -0
- package/dist/roster/context.d.ts +3 -0
- package/dist/roster/context.js +23 -0
- package/dist/roster/context.js.map +1 -0
- package/dist/roster/init.d.ts +15 -0
- package/dist/roster/init.js +52 -0
- package/dist/roster/init.js.map +1 -0
- package/dist/roster/roster.d.ts +79 -0
- package/dist/roster/roster.js +298 -0
- package/dist/roster/roster.js.map +1 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/agents/em-implementer.md +7 -4
- package/plugin/agents/em-validator.md +2 -2
- package/plugin/shared/reference/em-dsl.md +94 -6
- package/plugin/shared/reference/methodology.md +268 -0
- package/plugin/shared/reference/slice-doc-schema.md +96 -2
- package/plugin/shared/templates/slice.md +11 -3
- package/plugin/skills/conform/SKILL.md +1 -1
- package/plugin/skills/design/SKILL.md +46 -5
- package/plugin/skills/discover/SKILL.md +43 -8
- package/plugin/skills/discover/reference/extract.md +6 -1
- package/plugin/skills/discover/reference/prd-rubric.md +103 -0
- package/plugin/skills/discover/reference/prd-spec-path.md +172 -0
- package/plugin/skills/engagement/SKILL.md +21 -9
- package/plugin/skills/event-modeling/SKILL.md +1 -1
- package/plugin/skills/implement/SKILL.md +1 -1
- package/plugin/skills/implement/reference/implement.md +18 -0
- package/plugin/skills/review/SKILL.md +4 -2
package/dist/cli/upgrade.js
CHANGED
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
// ci-block/constitution check needs. Requires the model directory to be inside a git
|
|
22
22
|
// repository — same requirement `em conform-scope` holds, for the same reason (there is no
|
|
23
23
|
// "changed since when" or "commit per step" without one).
|
|
24
|
-
import { existsSync, readFileSync, realpathSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
26
|
+
import { findRosterAbove, GRANDFATHERED_RATIFIER } from "../roster/roster.js";
|
|
26
27
|
import { PUBLIC_SCALAR_TYPE_NAMES } from "../model/model.js";
|
|
27
28
|
import { findUnresolvedPublicFieldTypes } from "../model/validate.js";
|
|
28
29
|
import { realGit } from "./diff-inputs.js";
|
|
@@ -38,11 +39,17 @@ import { findSpecifyRoot, locateConstitution } from "./status.js";
|
|
|
38
39
|
import { scaffoldConstitution, scaffoldStateFile } from "../templates.js";
|
|
39
40
|
import { loadStateFile, parseState, setEmVersion, ensureOptionalBullets, resolveStateFilePath } from "./stateFile.js";
|
|
40
41
|
import { fieldLineRegex, normalizeFieldValue, locateFrontmatterInner, replaceFieldValue } from "./frontmatterSurgery.js";
|
|
41
|
-
import { ratifiedHashScope, sliceDocBodyHash } from "../catalog/sliceDoc.js";
|
|
42
|
-
import { applyDeferOrAppend, drainQueueInState, finishSignedDocEdit, isSignedDoc, readQueue } from "./defer.js";
|
|
42
|
+
import { parseSliceDoc, ratifiedHashScope, sliceDocBodyHash } from "../catalog/sliceDoc.js";
|
|
43
|
+
import { applyDeferOrAppend, drainQueueInState, finishSignedDocEdit, isSignedDoc, readQueue, rewriteParkingLotFragments } from "./defer.js";
|
|
44
|
+
import { runSliceSync } from "./sliceSync.js";
|
|
45
|
+
import { runSliceIndex } from "./sliceIndex.js";
|
|
46
|
+
import { planCollapseAlternativeEvents, planFoldContinuations, planStackContinuations } from "./upgradeReshape.js";
|
|
47
|
+
import { observeSigners } from "../roster/init.js";
|
|
43
48
|
import { STATE_FILE_NAME } from "./stateFile.js";
|
|
44
49
|
import { localIsoDate } from "../util/localDate.js";
|
|
45
50
|
import { compile } from "../pipeline.js";
|
|
51
|
+
import { computeModelKey } from "../model/qualifiedRef.js";
|
|
52
|
+
import { contractPathFor, generateContract } from "./api.js";
|
|
46
53
|
import { hasErrors } from "../model/validate.js";
|
|
47
54
|
import { LEGACY_SYSTEM_MANIFEST_SCHEMA_VERSION, parseManifest } from "../system/manifest.js";
|
|
48
55
|
import { planManifestMigration } from "../system/migrateManifest.js";
|
|
@@ -277,7 +284,10 @@ function ciBlockFiles(args, ctx) {
|
|
|
277
284
|
};
|
|
278
285
|
}
|
|
279
286
|
return {
|
|
280
|
-
ci: [
|
|
287
|
+
ci: [
|
|
288
|
+
buildCiWorkflowFile(args.model, args.testsDir, ctx.installedVersion, findRosterAbove(dirname(resolve(ctx.repoRoot, args.model))) !== null),
|
|
289
|
+
ciManagedBody(args.model, args.testsDir, ctx.installedVersion, findRosterAbove(dirname(resolve(ctx.repoRoot, args.model))) !== null),
|
|
290
|
+
],
|
|
281
291
|
conform: [buildConformWorkflowFile(args.model, ctx.installedVersion, usePlugin), conformManagedBody(args.model, ctx.installedVersion, usePlugin)],
|
|
282
292
|
};
|
|
283
293
|
}
|
|
@@ -333,6 +343,82 @@ function applyCiBlock(ctx) {
|
|
|
333
343
|
}
|
|
334
344
|
return { ok: true, changedFiles };
|
|
335
345
|
}
|
|
346
|
+
/** Every model covered by an `api-check` job whose `contracts/<model key>.tsp` is absent. The job
|
|
347
|
+
* counts when it is in the on-disk em-ci.yml OR when `ci-block` is pending (a stale block is
|
|
348
|
+
* rewritten to the current preset, which carries `api-check` - so a pre-1.12 workflow is caught by
|
|
349
|
+
* the dry run, not only after `--apply`). Models that can't be read or compile cleanly are
|
|
350
|
+
* returned in `blocked` (never skipped silently). */
|
|
351
|
+
function contractPlan(ctx) {
|
|
352
|
+
const none = { missing: [], blocked: [] };
|
|
353
|
+
const ciPath = ciWorkflowPath(ctx.repoRoot);
|
|
354
|
+
if (!existsSync(ciPath))
|
|
355
|
+
return none;
|
|
356
|
+
const text = readFileSync(ciPath, "utf8");
|
|
357
|
+
const plans = ciBlockPlans(ctx);
|
|
358
|
+
const hasJob = /^ {2}api-check(-[\w-]+)?:/m.test(text) || plans.ciStale;
|
|
359
|
+
if (!hasJob || !plans.args)
|
|
360
|
+
return none;
|
|
361
|
+
const paths = plans.args.models ? plans.args.models.map((m) => m.path) : [plans.args.model];
|
|
362
|
+
const missing = [];
|
|
363
|
+
const blocked = [];
|
|
364
|
+
for (const rel of paths) {
|
|
365
|
+
const modelFile = resolve(ctx.repoRoot, rel);
|
|
366
|
+
let source;
|
|
367
|
+
try {
|
|
368
|
+
source = readFileSync(modelFile, "utf8");
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
blocked.push({ model: rel, why: "the model file can't be read" });
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const compiled = compile(source);
|
|
376
|
+
if (hasErrors(compiled.diagnostics)) {
|
|
377
|
+
// Only blocked when the contract is actually absent (a model mid-edit that already has one is fine).
|
|
378
|
+
if (!existsSync(contractPathFor(modelFile, computeModelKey(compiled.model, modelFile))))
|
|
379
|
+
blocked.push({ model: rel, why: "it has compile errors" });
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const g = generateContract(modelFile, source, compiled);
|
|
383
|
+
if (!existsSync(g.contractPath))
|
|
384
|
+
missing.push({ modelFile, contractPath: g.contractPath, modelKey: g.modelKey });
|
|
385
|
+
}
|
|
386
|
+
catch (e) {
|
|
387
|
+
blocked.push({ model: rel, why: `it does not parse (${e.message})` });
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return { missing, blocked };
|
|
391
|
+
}
|
|
392
|
+
function blockedText(blocked) {
|
|
393
|
+
return blocked.map((b) => `${b.model} (${b.why})`).join("; ");
|
|
394
|
+
}
|
|
395
|
+
function detectApiContractMissing(ctx) {
|
|
396
|
+
const { missing, blocked } = contractPlan(ctx);
|
|
397
|
+
if (missing.length === 0) {
|
|
398
|
+
if (blocked.length)
|
|
399
|
+
return { applicable: false, reason: `the api-check contract could not be generated for ${blockedText(blocked)} - see the api-contract-blocked item` };
|
|
400
|
+
return { applicable: false, reason: "no api-check job lacking its contracts/<model key>.tsp" };
|
|
401
|
+
}
|
|
402
|
+
const skipped = blocked.length ? `; not generated for ${blockedText(blocked)} (see the api-contract-blocked item)` : "";
|
|
403
|
+
return {
|
|
404
|
+
applicable: true,
|
|
405
|
+
reason: `the api-check job needs ${missing.map((m) => relative(ctx.repoRoot, m.contractPath)).join(", ")}, which ${missing.length === 1 ? "is" : "are"} missing - em upgrade will run em api generate${skipped}`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
function applyApiContractMissing(ctx) {
|
|
409
|
+
const { missing } = contractPlan(ctx);
|
|
410
|
+
if (missing.length === 0)
|
|
411
|
+
return { ok: false, message: "nothing to generate" };
|
|
412
|
+
const changedFiles = [];
|
|
413
|
+
for (const m of missing) {
|
|
414
|
+
const source = readFileSync(m.modelFile, "utf8");
|
|
415
|
+
const generated = generateContract(m.modelFile, source, compile(source));
|
|
416
|
+
mkdirSync(dirname(generated.contractPath), { recursive: true });
|
|
417
|
+
writeFileSync(generated.contractPath, generated.text);
|
|
418
|
+
changedFiles.push(generated.contractPath);
|
|
419
|
+
}
|
|
420
|
+
return { ok: true, changedFiles };
|
|
421
|
+
}
|
|
336
422
|
// ---- Step 5: constitution — scaffold a draft when absent and no .specify/ owns the slot. ----
|
|
337
423
|
function detectConstitution(ctx) {
|
|
338
424
|
const existing = locateConstitution(ctx.baseDir);
|
|
@@ -363,7 +449,7 @@ function applyConstitution(ctx) {
|
|
|
363
449
|
// reratified doc has `version > 1` and is mid-sign-off, which only a human can resolve; see the
|
|
364
450
|
// `ready-to-implement-no-ratifiedby` human item) with no `ratifiedBy`. A doc with no `version:`
|
|
365
451
|
// at all is treated as version 1 (the schema's default). ----
|
|
366
|
-
export
|
|
452
|
+
export { GRANDFATHERED_RATIFIER };
|
|
367
453
|
/** Pure text transform: inserts the grandfather sign-off after the `status:` line, using only
|
|
368
454
|
* the key lines that are missing (a stray `ratifiedOn:` is kept as-is), in the file's own EOL.
|
|
369
455
|
* `null` when the doc isn't a candidate (no frontmatter/status, or `ratifiedBy` already set). */
|
|
@@ -547,6 +633,298 @@ function applyRatifiedHashScope(ctx) {
|
|
|
547
633
|
}
|
|
548
634
|
return { ok: true, changedFiles };
|
|
549
635
|
}
|
|
636
|
+
// ---- Step 8-: collapse-alternative-events (GH #290 / #278) and fold-continuations (GH #290 / #287) ----
|
|
637
|
+
// The two steps that rewrite the `.em` itself (planned by upgradeReshape.ts: text surgery, verified by
|
|
638
|
+
// recompiling before anything is written). They run before the doc/index steps, which re-read the
|
|
639
|
+
// model `applyUpgrade` recompiles after each step. `fold-continuations` is OPT-IN (see below).
|
|
640
|
+
function readModelText(ctx) {
|
|
641
|
+
return readFileSync(ctx.modelFile, "utf8");
|
|
642
|
+
}
|
|
643
|
+
function freshCompile(ctx) {
|
|
644
|
+
try {
|
|
645
|
+
const c = compile(readModelText(ctx));
|
|
646
|
+
return { model: c.model, refs: c.refs };
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/** Signed docs (ratified or shipped) whose generated regions would be stale against `nextText`, i.e. that
|
|
653
|
+
* a rewrite leaves behind a signature the regions no longer match. Read-only. */
|
|
654
|
+
function signedDocsStaleAfter(ctx, nextText) {
|
|
655
|
+
const staleSigned = (src) => {
|
|
656
|
+
const out = new Map();
|
|
657
|
+
let c;
|
|
658
|
+
try {
|
|
659
|
+
c = compile(src);
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
return out;
|
|
663
|
+
}
|
|
664
|
+
const r = runSliceSync(c.model, c.refs, ctx.baseDir, undefined, true);
|
|
665
|
+
if (!r.ok)
|
|
666
|
+
return out;
|
|
667
|
+
for (const d of r.docs) {
|
|
668
|
+
if (d.status === "stale" && isSignedDoc(readFileSync(join(ctx.baseDir, d.path), "utf8"))) {
|
|
669
|
+
out.set(d.path, `${d.path} (${d.regions.filter((g) => g.status === "stale").map((g) => g.name).join(", ")})`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return out;
|
|
673
|
+
};
|
|
674
|
+
const before = staleSigned(readModelText(ctx));
|
|
675
|
+
return [...staleSigned(nextText)].filter(([path]) => !before.has(path)).map(([, label]) => label); // newly stale only
|
|
676
|
+
}
|
|
677
|
+
const CARRIED_SECTIONS = /^##\s+(Scenarios|Invariants|Open Questions|Decisions(?: log)?)\s*$/i;
|
|
678
|
+
/** Sections of a retiring doc whose AUTHORED content a human must carry to the kept doc (generated
|
|
679
|
+
* regions are rebuilt from the model, so they are not content). */
|
|
680
|
+
function carriedSections(raw) {
|
|
681
|
+
const body = raw.replace(/<!-- GENERATED:[^>]*:start[^>]*-->[\s\S]*?<!-- GENERATED:[^>]*:end -->/g, "");
|
|
682
|
+
const lines = body.split("\n");
|
|
683
|
+
const found = [];
|
|
684
|
+
for (let i = 0; i < lines.length; i++) {
|
|
685
|
+
const m = CARRIED_SECTIONS.exec(lines[i]);
|
|
686
|
+
if (!m)
|
|
687
|
+
continue;
|
|
688
|
+
let j = i + 1;
|
|
689
|
+
let content = false;
|
|
690
|
+
for (; j < lines.length && !/^#{1,2}\s/.test(lines[j]); j++)
|
|
691
|
+
if (lines[j].trim() !== "" && !lines[j].includes("{{"))
|
|
692
|
+
content = true;
|
|
693
|
+
if (content)
|
|
694
|
+
found.push(m[1]);
|
|
695
|
+
}
|
|
696
|
+
return found;
|
|
697
|
+
}
|
|
698
|
+
/** Where a collapsed slice's retired doc goes: beside the model, outside `slices/` (so it is not an
|
|
699
|
+
* orphaned slice doc), keeping its file name. */
|
|
700
|
+
const ARCHIVE_DIR = join("archive", "slices");
|
|
701
|
+
function retiredDocs(ctx, groups) {
|
|
702
|
+
const out = [];
|
|
703
|
+
const refOf = elementRefOf(ctx);
|
|
704
|
+
const joinOf = (key) => {
|
|
705
|
+
const i = ctx.refs.sliceKeys.indexOf(key);
|
|
706
|
+
return i === -1 ? null : resolveSliceDocJoin(ctx.model, ctx.refs, ctx.model.slices[i], key, ctx.baseDir, refOf).doc;
|
|
707
|
+
};
|
|
708
|
+
for (const g of groups) {
|
|
709
|
+
const kept = joinOf(g.keys[0]);
|
|
710
|
+
const keptDoc = kept && kept.found && kept.reason === null ? kept.path : null;
|
|
711
|
+
const keptVersion = kept && keptDoc ? kept.version : null;
|
|
712
|
+
for (const key of g.keys.slice(1)) {
|
|
713
|
+
const bound = joinOf(key);
|
|
714
|
+
const rel = bound && bound.found && bound.reason === null ? bound.path : `slices/${key}.md`;
|
|
715
|
+
const from = join(ctx.baseDir, rel);
|
|
716
|
+
if (!existsSync(from) || (keptDoc !== null && rel === keptDoc))
|
|
717
|
+
continue;
|
|
718
|
+
const raw = readFileSync(from, "utf8");
|
|
719
|
+
const range = locateFrontmatterInner(raw);
|
|
720
|
+
const retiredVersion = Number(/^version:\s*(\d+)/m.exec(raw)?.[1] ?? "1");
|
|
721
|
+
const target = keptDoc !== null ? basename(keptDoc, ".md") : g.keys[0];
|
|
722
|
+
let next = raw;
|
|
723
|
+
if (range) {
|
|
724
|
+
const fenceEnd = raw.indexOf("\n", range.innerEnd) + 1 || raw.length;
|
|
725
|
+
const banner = `\n> **SUPERSEDED** by \`${target}\` (\`superseded-by\`): this slice was collapsed into one recording alternative events (GH #278). Kept for history; fold anything worth keeping into \`${keptDoc ?? `slices/${g.keys[0]}.md`}\`.\n`;
|
|
726
|
+
next = raw.slice(0, range.innerEnd) + `superseded-by: ${target}@v${keptVersion ?? 1}\n` + raw.slice(range.innerEnd, fenceEnd) + banner + raw.slice(fenceEnd);
|
|
727
|
+
}
|
|
728
|
+
out.push({ from, to: join(ctx.baseDir, ARCHIVE_DIR, basename(rel)), key, keptKey: g.keys[0], keptDoc, keptVersion, retiredVersion, text: next, carried: carriedSections(raw) });
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return out;
|
|
732
|
+
}
|
|
733
|
+
function carriedNotes(retired) {
|
|
734
|
+
return retired.filter((r) => r.carried.length > 0).map((r) => `${ARCHIVE_DIR}/${basename(r.to)} holds ${r.carried.join(", ")} — fold these into ${r.keptDoc ?? `slices/${r.keptKey}.md`}`);
|
|
735
|
+
}
|
|
736
|
+
function detectCollapseAlternativeEvents(ctx) {
|
|
737
|
+
const fresh = freshCompile(ctx);
|
|
738
|
+
const plan = fresh ? planCollapseAlternativeEvents(fresh.model, fresh.refs, readModelText(ctx)) : null;
|
|
739
|
+
if (plan === null)
|
|
740
|
+
return { applicable: false, reason: "no run of adjacent slices repeats one command and differs only in the recorded event" };
|
|
741
|
+
const desc = plan.groups.map((g) => `${g.slices.map((n) => `"${n}"`).join(" + ")} → "${g.slices[0]}" recording ${g.form === "inline" ? g.events.join(" | ") : `oneof { ${g.events.join(", ")} }`}`);
|
|
742
|
+
const retired = retiredDocs(ctx, plan.groups);
|
|
743
|
+
const notes = carriedNotes(retired);
|
|
744
|
+
const stale = signedDocsStaleAfter(ctx, plan.next);
|
|
745
|
+
return {
|
|
746
|
+
applicable: true,
|
|
747
|
+
reason: `${plan.groups.length} run(s) of repeated-command slices collapse to alternative events: ${desc.join("; ")} — em upgrade will rewrite them (the first slice's key is kept; the others' docs move to ${ARCHIVE_DIR}/ with superseded-by and a SUPERSEDED banner, and the kept doc gains merged-from)` +
|
|
748
|
+
(notes.length > 0 ? `; ${notes.join("; ")}` : "") +
|
|
749
|
+
(stale.length > 0 ? `; signed doc(s) will not be synced and go stale: ${stale.join(", ")} — em slice revise <key>, then em slice sync` : ""),
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
/** Frontmatter-only edit: adds `key: value` before the closing fence (a no-op without frontmatter). */
|
|
753
|
+
function addFrontmatterLine(raw, line) {
|
|
754
|
+
const range = locateFrontmatterInner(raw);
|
|
755
|
+
return range ? raw.slice(0, range.innerEnd) + line + "\n" + raw.slice(range.innerEnd) : raw;
|
|
756
|
+
}
|
|
757
|
+
function applyCollapseAlternativeEvents(ctx) {
|
|
758
|
+
const fresh = freshCompile(ctx);
|
|
759
|
+
const text = readModelText(ctx);
|
|
760
|
+
const plan = fresh ? planCollapseAlternativeEvents(fresh.model, fresh.refs, text) : null;
|
|
761
|
+
if (plan === null)
|
|
762
|
+
return { ok: false, message: "the model no longer collapses cleanly" };
|
|
763
|
+
const changed = [ctx.modelFile];
|
|
764
|
+
const retired = retiredDocs(ctx, plan.groups); // computed on the OLD model (joins, versions)
|
|
765
|
+
const notes = carriedNotes(retired);
|
|
766
|
+
writeFileSync(ctx.modelFile, plan.next, "utf8");
|
|
767
|
+
for (const r of retired) {
|
|
768
|
+
mkdirSync(dirname(r.to), { recursive: true });
|
|
769
|
+
writeFileSync(r.to, r.text, "utf8");
|
|
770
|
+
rmSync(r.from);
|
|
771
|
+
changed.push(r.from, r.to);
|
|
772
|
+
}
|
|
773
|
+
// Lineage back-link on the kept doc: `merged-from: <retired>@v<N>` (frontmatter only, outside the
|
|
774
|
+
// ratified hash). A SIGNED kept doc is not touched; `lineage-backlink-missing` lists it.
|
|
775
|
+
for (const r of retired) {
|
|
776
|
+
if (r.keptDoc === null)
|
|
777
|
+
continue;
|
|
778
|
+
const abs = join(ctx.baseDir, r.keptDoc);
|
|
779
|
+
const raw = readFileSync(abs, "utf8");
|
|
780
|
+
if (isSignedDoc(raw))
|
|
781
|
+
continue;
|
|
782
|
+
const ref = `${basename(r.to, ".md")}@v${r.retiredVersion}`;
|
|
783
|
+
const existing = /^merged-from:\s*(.*)$/m.exec(raw);
|
|
784
|
+
const next = existing ? raw.replace(/^merged-from:\s*(.*)$/m, (_m, v) => `merged-from: ${String(v).trim() === "" ? ref : `${String(v).trim()}, ${ref}`}`) : addFrontmatterLine(raw, `merged-from: ${ref}`);
|
|
785
|
+
writeFileSync(abs, next, "utf8");
|
|
786
|
+
if (!changed.includes(abs))
|
|
787
|
+
changed.push(abs);
|
|
788
|
+
}
|
|
789
|
+
// Regenerate the kept slice's event region (one `Records one of:` region now) unless its doc is signed
|
|
790
|
+
// (then `slice-doc-stale-signed` is the human item that says what to do).
|
|
791
|
+
const after = freshCompile(ctx);
|
|
792
|
+
if (after) {
|
|
793
|
+
for (const g of plan.groups) {
|
|
794
|
+
const kept = retired.find((r) => r.keptKey === g.keys[0])?.keptDoc ?? `slices/${g.keys[0]}.md`;
|
|
795
|
+
const doc = join(ctx.baseDir, kept);
|
|
796
|
+
if (existsSync(doc) && !isSignedDoc(readFileSync(doc, "utf8"))) {
|
|
797
|
+
const r = runSliceSync(after.model, after.refs, ctx.baseDir, g.keys[0], false);
|
|
798
|
+
if (r.ok && r.docs.some((d) => d.status === "synced") && !changed.includes(doc))
|
|
799
|
+
changed.push(doc);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return { ok: true, changedFiles: changed, notes };
|
|
804
|
+
}
|
|
805
|
+
// ---- fold-continuations is OPT-IN (`em upgrade --apply --fold-continuations`) ----
|
|
806
|
+
// Default form (a): consecutive continuations of ONE view stack into the first slice (its key, name,
|
|
807
|
+
// `continuationOf`, doc join and index row stay). `--to-sugar` instead moves them into the host
|
|
808
|
+
// command slice as `view X again … shown on` lines; that CHANGES `continuationOf` and the doc join to
|
|
809
|
+
// the host (`<host>--again`) and removes the index rows.
|
|
810
|
+
const FOLD_FLAG = "em upgrade --apply --fold-continuations";
|
|
811
|
+
function foldPlan(ctx) {
|
|
812
|
+
const fresh = freshCompile(ctx);
|
|
813
|
+
if (!fresh)
|
|
814
|
+
return { kind: "stack", plan: null };
|
|
815
|
+
const text = readModelText(ctx);
|
|
816
|
+
return ctx.toSugar === true
|
|
817
|
+
? { kind: "sugar", plan: planFoldContinuations(fresh.model, fresh.refs, text) }
|
|
818
|
+
: { kind: "stack", plan: planStackContinuations(fresh.model, fresh.refs, text) };
|
|
819
|
+
}
|
|
820
|
+
function detectFoldContinuations(ctx) {
|
|
821
|
+
const f = foldPlan(ctx);
|
|
822
|
+
if (f.plan === null)
|
|
823
|
+
return { applicable: false, reason: "no consecutive hand-named continuation slices of one view (stack) — or, with --to-sugar, no continuation directly after its host command — in a foldable shape" };
|
|
824
|
+
if (f.kind === "sugar" && f.plan.groups.length === 0) {
|
|
825
|
+
const r = f.plan.refused;
|
|
826
|
+
return { applicable: false, reason: `--to-sugar: nothing folded; refused: ${r.map((x) => `${x.hostKey} (${x.reason})`).join("; ")}` };
|
|
827
|
+
}
|
|
828
|
+
const optIn = ctx.foldContinuations === true || ctx.toSugar === true;
|
|
829
|
+
const prefix = optIn ? "" : `OPT-IN, not applied by a plain --apply (run \`${FOLD_FLAG}\`): `;
|
|
830
|
+
const stale = signedDocsStaleAfter(ctx, f.plan.next);
|
|
831
|
+
const staleNote = stale.length > 0 ? `; signed doc(s) whose generated regions (e.g. the view's \`via\` annotations) go stale and will not be synced: ${stale.join(", ")} — em slice revise <key>, then em slice sync` : "";
|
|
832
|
+
if (f.kind === "stack") {
|
|
833
|
+
const p = f.plan;
|
|
834
|
+
const desc = p.groups.map((g) => `${[g.kept, ...g.dropped].map((n) => `"${n}"`).join(" + ")} → one continuation slice "${g.kept}" (key ${g.keptKey} kept; dropped keys: ${g.droppedKeys.join(", ")})`);
|
|
835
|
+
const skipped = p.skipped.length > 0 ? `; left unstacked: ${p.skipped.map((s) => `${s.key} (${s.reason})`).join(", ")}` : "";
|
|
836
|
+
return { applicable: true, reason: `${prefix}${desc.join("; ")} — continuationOf, the doc join and the kept key's index row are unchanged${skipped}${staleNote}` };
|
|
837
|
+
}
|
|
838
|
+
const p = f.plan;
|
|
839
|
+
const desc = p.groups.map((g) => `${g.folded.map((n) => `"${n}"`).join(" + ")} (keys ${g.foldedKeys.join(", ")}) → \`view … again\` lines in "${g.host}" (column ${g.hostKey}--again)`);
|
|
840
|
+
const refusedNote = p.refused.length > 0 ? `; refused: ${p.refused.map((x) => `${x.hostKey} (${x.reason})`).join("; ")}` : "";
|
|
841
|
+
return { applicable: true, reason: `${prefix}--to-sugar: ${desc.join("; ")}${refusedNote} — CHANGES continuationOf and the doc join to the host (${p.groups.map((g) => `${g.hostKey}--again`).join(", ")}) and removes the index rows of ${p.groups.flatMap((g) => g.foldedKeys).join(", ")} (a stray own doc of a folded slice is left alone)${staleNote}` };
|
|
842
|
+
}
|
|
843
|
+
function applyFoldContinuations(ctx) {
|
|
844
|
+
const f = foldPlan(ctx);
|
|
845
|
+
if (f.plan === null || ("groups" in f.plan && f.plan.groups.length === 0))
|
|
846
|
+
return { ok: false, message: "the model no longer folds cleanly" };
|
|
847
|
+
writeFileSync(ctx.modelFile, f.plan.next, "utf8");
|
|
848
|
+
return { ok: true, changedFiles: [ctx.modelFile] };
|
|
849
|
+
}
|
|
850
|
+
function staleViewDocs(ctx) {
|
|
851
|
+
const open = [];
|
|
852
|
+
const signed = [];
|
|
853
|
+
const r = runSliceSync(ctx.model, ctx.refs, ctx.baseDir, undefined, true);
|
|
854
|
+
if (!r.ok)
|
|
855
|
+
return { open, signed };
|
|
856
|
+
for (const d of r.docs) {
|
|
857
|
+
if (d.status !== "stale" || !d.regions.some((g) => g.status === "stale" && g.name.startsWith("em-slice-view")))
|
|
858
|
+
continue;
|
|
859
|
+
(isSignedDoc(readFileSync(join(ctx.baseDir, d.path), "utf8")) ? signed : open).push({ key: d.key, path: d.path });
|
|
860
|
+
}
|
|
861
|
+
return { open, signed };
|
|
862
|
+
}
|
|
863
|
+
function detectViewDocSourcesStale(ctx) {
|
|
864
|
+
const { open, signed } = staleViewDocs(ctx);
|
|
865
|
+
const left = signed.length > 0 ? `; left alone (ratified or shipped — em slice revise, then em slice sync): ${signed.map((d) => d.path).join(", ")}` : "";
|
|
866
|
+
if (open.length === 0) {
|
|
867
|
+
return { applicable: false, reason: `no bound slice doc's view region is stale against the model${left}` };
|
|
868
|
+
}
|
|
869
|
+
return {
|
|
870
|
+
applicable: true,
|
|
871
|
+
reason: `em slice sync --check would change the view region of ${open.length} doc(s): ${open.map((d) => d.path).join(", ")} — em upgrade will run em slice sync on them${left}`,
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
function applyViewDocSourcesStale(ctx) {
|
|
875
|
+
const { open } = staleViewDocs(ctx);
|
|
876
|
+
const changedFiles = [];
|
|
877
|
+
for (const d of open) {
|
|
878
|
+
const r = runSliceSync(ctx.model, ctx.refs, ctx.baseDir, d.key, false);
|
|
879
|
+
if (!r.ok)
|
|
880
|
+
return { ok: false, message: r.message };
|
|
881
|
+
changedFiles.push(join(ctx.baseDir, d.path));
|
|
882
|
+
}
|
|
883
|
+
return { ok: true, changedFiles };
|
|
884
|
+
}
|
|
885
|
+
// ---- Step 8b: slice-index-stale (GH #290 / #275) — the README Slices table. ----
|
|
886
|
+
function detectSliceIndexStale(ctx) {
|
|
887
|
+
const r = runSliceIndex(ctx.model, ctx.refs, ctx.modelFile, true);
|
|
888
|
+
if (r.ok)
|
|
889
|
+
return { applicable: false, reason: "the README Slices table is current" };
|
|
890
|
+
if (r.kind === "stale")
|
|
891
|
+
return { applicable: true, reason: `${join(ctx.baseDir, "README.md")}'s Slices table is stale (em slice index --check) — em upgrade will regenerate it` };
|
|
892
|
+
return { applicable: false, reason: r.kind === "missing-readme" ? "no README.md beside the model — nothing to index" : "README.md has no GENERATED:slices markers — nothing to index" };
|
|
893
|
+
}
|
|
894
|
+
function applySliceIndexStale(ctx) {
|
|
895
|
+
const r = runSliceIndex(ctx.model, ctx.refs, ctx.modelFile, false);
|
|
896
|
+
if (!r.ok)
|
|
897
|
+
return { ok: false, message: r.message };
|
|
898
|
+
return { ok: true, changedFiles: r.wrote ? [r.readmePath] : [] };
|
|
899
|
+
}
|
|
900
|
+
// ---- Step 8c: parking-lot-fragments (GH #290 / #283) — substring bullets become the item's label. ----
|
|
901
|
+
function parkingLotDocs(ctx) {
|
|
902
|
+
const docs = new Map();
|
|
903
|
+
for (const d of nonContinuationDocs(ctx)) {
|
|
904
|
+
const file = join(ctx.baseDir, d.path);
|
|
905
|
+
if (d.path !== "" && existsSync(file))
|
|
906
|
+
docs.set(d.key, readFileSync(file, "utf8"));
|
|
907
|
+
}
|
|
908
|
+
return docs;
|
|
909
|
+
}
|
|
910
|
+
function detectParkingLotFragments(ctx) {
|
|
911
|
+
const statePath = join(ctx.baseDir, STATE_FILE_NAME);
|
|
912
|
+
if (!existsSync(statePath))
|
|
913
|
+
return { applicable: false, reason: `no ${STATE_FILE_NAME} beside the model` };
|
|
914
|
+
const { rewrites } = rewriteParkingLotFragments(readFileSync(statePath, "utf8"), parkingLotDocs(ctx));
|
|
915
|
+
if (rewrites.length === 0)
|
|
916
|
+
return { applicable: false, reason: "no parking-lot bullet is a fragment of an Open Questions item" };
|
|
917
|
+
return {
|
|
918
|
+
applicable: true,
|
|
919
|
+
reason: `${rewrites.length} parking-lot bullet(s) hold a fragment of an Open Questions item (the pre-1.18 em slice defer shape): ${rewrites.map((r) => `"${r.from}" → "${r.to}"`).join("; ")} — em upgrade will rewrite each to the item's label`,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
function applyParkingLotFragments(ctx) {
|
|
923
|
+
const statePath = join(ctx.baseDir, STATE_FILE_NAME);
|
|
924
|
+
const { content } = rewriteParkingLotFragments(readFileSync(statePath, "utf8"), parkingLotDocs(ctx));
|
|
925
|
+
writeFileSync(statePath, content, "utf8");
|
|
926
|
+
return { ok: true, changedFiles: [statePath] };
|
|
927
|
+
}
|
|
550
928
|
// ---- Step 8: system-manifest (MIL-235, R3) — system.yaml 1.0 -> 2.0. ----
|
|
551
929
|
// The one mechanical step that writes OTHER models' files: the legacy manifest's seams become
|
|
552
930
|
// `consumes` clauses on each consuming translation and its owners become `owner "…"` on each
|
|
@@ -645,9 +1023,15 @@ export const UPGRADE_STEPS = [
|
|
|
645
1023
|
{ id: "reaction-shape", sinceVersion: "1.8.0", detect: detectReactionShape, apply: applyReactionShape },
|
|
646
1024
|
{ id: "state-file", sinceVersion: "1.13.0", detect: detectStateFile, apply: applyStateFile },
|
|
647
1025
|
{ id: "ci-block", sinceVersion: "1.9.0", detect: detectCiBlock, apply: applyCiBlock },
|
|
1026
|
+
{ id: "api-contract-missing", sinceVersion: "1.18.1", detect: detectApiContractMissing, apply: applyApiContractMissing },
|
|
648
1027
|
{ id: "constitution", sinceVersion: "1.11.0", detect: detectConstitution, apply: applyConstitution },
|
|
649
1028
|
{ id: "ratified-signoff", sinceVersion: "1.14.0", detect: detectRatifiedSignoff, apply: applyRatifiedSignoff },
|
|
650
1029
|
{ id: "ratified-hash-scope", sinceVersion: "1.17.0", detect: detectRatifiedHashScope, apply: applyRatifiedHashScope },
|
|
1030
|
+
{ id: "collapse-alternative-events", sinceVersion: "1.18.0", detect: detectCollapseAlternativeEvents, apply: applyCollapseAlternativeEvents },
|
|
1031
|
+
{ id: "fold-continuations", sinceVersion: "1.18.0", detect: detectFoldContinuations, apply: applyFoldContinuations, optedIn: (c) => c.foldContinuations === true || c.toSugar === true },
|
|
1032
|
+
{ id: "view-doc-sources-stale", sinceVersion: "1.18.0", detect: detectViewDocSourcesStale, apply: applyViewDocSourcesStale },
|
|
1033
|
+
{ id: "slice-index-stale", sinceVersion: "1.18.0", detect: detectSliceIndexStale, apply: applySliceIndexStale },
|
|
1034
|
+
{ id: "parking-lot-fragments", sinceVersion: "1.18.0", detect: detectParkingLotFragments, apply: applyParkingLotFragments },
|
|
651
1035
|
{ id: "system-manifest", sinceVersion: "1.14.0", detect: detectSystemManifest, apply: applySystemManifest },
|
|
652
1036
|
];
|
|
653
1037
|
function elementRefOf(ctx) {
|
|
@@ -780,6 +1164,111 @@ function detectPluginInstallLocally(ctx) {
|
|
|
780
1164
|
}
|
|
781
1165
|
return null;
|
|
782
1166
|
}
|
|
1167
|
+
/** GH #290: a SIGNED doc (ratified or shipped) whose generated regions are stale against the model —
|
|
1168
|
+
* `view-doc-sources-stale` and `collapse-alternative-events` leave it alone because syncing would move
|
|
1169
|
+
* its `ratifiedHash`. Raised for any region (view, event, command, invariants). */
|
|
1170
|
+
function detectSliceDocStaleSigned(ctx) {
|
|
1171
|
+
const r = runSliceSync(ctx.model, ctx.refs, ctx.baseDir, undefined, true);
|
|
1172
|
+
if (!r.ok)
|
|
1173
|
+
return null;
|
|
1174
|
+
const docs = r.docs.filter((d) => d.status === "stale" && isSignedDoc(readFileSync(join(ctx.baseDir, d.path), "utf8")));
|
|
1175
|
+
if (docs.length === 0)
|
|
1176
|
+
return null;
|
|
1177
|
+
return {
|
|
1178
|
+
id: "slice-doc-stale-signed",
|
|
1179
|
+
reason: `${docs.map((d) => `${d.path} (${d.regions.filter((g) => g.status === "stale").map((g) => g.name).join(", ")})`).join("; ")} — ratified or shipped and stale against the model; run \`em slice revise ${docs.map((d) => d.key).join(" / ")}\`, then \`em slice sync\``,
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
/** GH #290 / #278: a retired doc under archive/slices/ names its successor in `superseded-by`, but the
|
|
1183
|
+
* successor (a signed doc `em upgrade` did not touch) lacks the `merged-from` back-link. */
|
|
1184
|
+
function detectLineageBacklinkMissing(ctx) {
|
|
1185
|
+
const dir = join(ctx.baseDir, ARCHIVE_DIR);
|
|
1186
|
+
if (!existsSync(dir))
|
|
1187
|
+
return null;
|
|
1188
|
+
const missing = [];
|
|
1189
|
+
for (const f of readdirSync(dir).filter((n) => n.endsWith(".md")).sort()) {
|
|
1190
|
+
const retired = parseSliceDoc(readFileSync(join(dir, f), "utf8"));
|
|
1191
|
+
for (const ref of retired.supersededBy) {
|
|
1192
|
+
if (!ref.sliceKey)
|
|
1193
|
+
continue;
|
|
1194
|
+
const target = join(ctx.baseDir, "slices", `${ref.sliceKey}.md`);
|
|
1195
|
+
if (!existsSync(target))
|
|
1196
|
+
continue;
|
|
1197
|
+
const doc = parseSliceDoc(readFileSync(target, "utf8"));
|
|
1198
|
+
const key = basename(f, ".md");
|
|
1199
|
+
if (!doc.mergedFrom.some((m) => m.sliceKey === key))
|
|
1200
|
+
missing.push(`slices/${ref.sliceKey}.md needs merged-from: ${key}@v${retired.version ?? 1}`);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (missing.length === 0)
|
|
1204
|
+
return null;
|
|
1205
|
+
return { id: "lineage-backlink-missing", reason: `${missing.join("; ")} (the doc is signed or was edited since; add the line to its frontmatter — it is outside the ratified hash)` };
|
|
1206
|
+
}
|
|
1207
|
+
/** GH #290 / #287: `fold-continuations` is detected and listed by every run but needs the flag. */
|
|
1208
|
+
function detectFoldContinuationsOptIn(ctx) {
|
|
1209
|
+
if (ctx.foldContinuations === true || ctx.toSugar === true)
|
|
1210
|
+
return null;
|
|
1211
|
+
const d = detectFoldContinuations(ctx);
|
|
1212
|
+
return d.applicable ? { id: "fold-continuations-opt-in", reason: d.reason } : null;
|
|
1213
|
+
}
|
|
1214
|
+
function compareVersions(a, b) {
|
|
1215
|
+
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
|
1216
|
+
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
1217
|
+
for (let i = 0; i < 3; i++)
|
|
1218
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
1219
|
+
return (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
1220
|
+
return 0;
|
|
1221
|
+
}
|
|
1222
|
+
/** The version the repo is upgrading FROM (recorded `Em version:` bullet, else inferred). */
|
|
1223
|
+
function recordedFromVersion(ctx) {
|
|
1224
|
+
const loaded = loadStateFile(ctx.baseDir);
|
|
1225
|
+
let recorded = null;
|
|
1226
|
+
if (loaded.ok) {
|
|
1227
|
+
const parsed = parseState(loaded.text);
|
|
1228
|
+
if (parsed.ok)
|
|
1229
|
+
recorded = parsed.state.emVersion;
|
|
1230
|
+
}
|
|
1231
|
+
return resolveFromVersion(ctx, recorded).version;
|
|
1232
|
+
}
|
|
1233
|
+
/** GH #290 / #259: signatures are a human decision, so the roster is never generated silently. */
|
|
1234
|
+
function detectRosterNotInitialised(ctx) {
|
|
1235
|
+
if (findRosterAbove(ctx.baseDir) !== null)
|
|
1236
|
+
return null;
|
|
1237
|
+
const { ratifiers } = observeSigners(ctx.model, ctx.refs, ctx.baseDir);
|
|
1238
|
+
if (ratifiers.length === 0)
|
|
1239
|
+
return null;
|
|
1240
|
+
return {
|
|
1241
|
+
id: "roster-not-initialised",
|
|
1242
|
+
reason: `${ratifiers.length} ratifier(s) have signed slice docs (${ratifiers.join(", ")}) and there is no ratifiers.yaml — decide who may sign, then run \`em roster init ${basename(ctx.modelFile)}\` to seed one from those names and review it before committing`,
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
/** GH #290 / #279: information only. Since 1.18.0 the same event recorded by several commands (same
|
|
1246
|
+
* name and context) is one logical event and `duplicate-name` no longer fires; nothing to change. */
|
|
1247
|
+
function detectSharedEventNowLegal(ctx) {
|
|
1248
|
+
// The transition only: a repo already stamped 1.18.0+ has seen this note.
|
|
1249
|
+
if (compareVersions(recordedFromVersion(ctx), "1.18.0") >= 0)
|
|
1250
|
+
return null;
|
|
1251
|
+
const shared = new Set();
|
|
1252
|
+
for (const el of ctx.model.elements)
|
|
1253
|
+
if (el.kind === "event" && el.logicalId !== el.id)
|
|
1254
|
+
shared.add(el.name);
|
|
1255
|
+
if (shared.size === 0)
|
|
1256
|
+
return null;
|
|
1257
|
+
return {
|
|
1258
|
+
id: "shared-event-now-legal",
|
|
1259
|
+
reason: `information only: ${[...shared].join(", ")} ${shared.size === 1 ? "is" : "are"} recorded by more than one command — legal since 1.18.0 (one logical event; duplicate-name no longer fires), no model change needed`,
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
/** GH #331: the api-check job needs a contract the mechanical step could not generate. */
|
|
1263
|
+
function detectApiContractBlocked(ctx) {
|
|
1264
|
+
const { blocked } = contractPlan(ctx);
|
|
1265
|
+
if (blocked.length === 0)
|
|
1266
|
+
return null;
|
|
1267
|
+
return {
|
|
1268
|
+
id: "api-contract-blocked",
|
|
1269
|
+
reason: `the contract could not be generated for ${blockedText(blocked)}, so the api-check job will fail until it exists - run \`em validate <model>\` to see why, fix it, then \`em api generate <model>\``,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
783
1272
|
const HUMAN_DETECTORS = [
|
|
784
1273
|
detectNoModelVersion,
|
|
785
1274
|
detectContinuationHasOwnDoc,
|
|
@@ -789,6 +1278,12 @@ const HUMAN_DETECTORS = [
|
|
|
789
1278
|
detectPredates16,
|
|
790
1279
|
detectPublicFieldTypesUnresolved,
|
|
791
1280
|
detectPluginInstallLocally,
|
|
1281
|
+
detectSliceDocStaleSigned,
|
|
1282
|
+
detectLineageBacklinkMissing,
|
|
1283
|
+
detectFoldContinuationsOptIn,
|
|
1284
|
+
detectSharedEventNowLegal,
|
|
1285
|
+
detectRosterNotInitialised,
|
|
1286
|
+
detectApiContractBlocked,
|
|
792
1287
|
];
|
|
793
1288
|
export function detectHumanItems(ctx) {
|
|
794
1289
|
return HUMAN_DETECTORS.map((d) => d(ctx)).filter((x) => x !== null);
|
|
@@ -884,7 +1379,8 @@ export function detectUpgrade(ctx) {
|
|
|
884
1379
|
const from = resolveFromVersion(ctx, recorded);
|
|
885
1380
|
const steps = UPGRADE_STEPS.map((s) => {
|
|
886
1381
|
const d = s.detect(ctx);
|
|
887
|
-
|
|
1382
|
+
const optIn = s.optedIn !== undefined && !s.optedIn(ctx) && d.applicable;
|
|
1383
|
+
return { id: s.id, sinceVersion: s.sinceVersion, applicable: d.applicable, human: d.human === true, reason: d.reason, ...(optIn ? { optIn: true } : {}) };
|
|
888
1384
|
});
|
|
889
1385
|
const human = detectHumanItems(ctx);
|
|
890
1386
|
return { from, to: ctx.installedVersion, steps, human, stateFileError };
|
|
@@ -921,6 +1417,10 @@ export function applyUpgrade(ctx, runGit = realGit) {
|
|
|
921
1417
|
applied.push({ id: step.id, applied: false, changedFiles: [], commit: null });
|
|
922
1418
|
continue;
|
|
923
1419
|
}
|
|
1420
|
+
if (step.optedIn && !step.optedIn(ctx)) {
|
|
1421
|
+
applied.push({ id: step.id, applied: false, changedFiles: [], commit: null });
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
924
1424
|
const result = step.apply(ctx);
|
|
925
1425
|
if (!result.ok) {
|
|
926
1426
|
gitDiscardAll(ctx.repoRoot, runGit);
|
|
@@ -931,8 +1431,17 @@ export function applyUpgrade(ctx, runGit = realGit) {
|
|
|
931
1431
|
if (!commitResult.ok) {
|
|
932
1432
|
return { ok: false, message: `em upgrade: step "${step.id}" applied but failed to commit — ${commitResult.message}`, report, applied };
|
|
933
1433
|
}
|
|
934
|
-
applied.push({ id: step.id, applied: true, changedFiles: result.changedFiles, commit: message });
|
|
1434
|
+
applied.push({ id: step.id, applied: true, changedFiles: result.changedFiles, commit: message, ...(result.notes && result.notes.length > 0 ? { notes: result.notes } : {}) });
|
|
1435
|
+
// A step may have rewritten the model (or its docs): later steps must see the new one.
|
|
1436
|
+
const fresh = freshCompile(ctx);
|
|
1437
|
+
if (fresh) {
|
|
1438
|
+
ctx.model = fresh.model;
|
|
1439
|
+
ctx.refs = fresh.refs;
|
|
1440
|
+
}
|
|
935
1441
|
}
|
|
1442
|
+
// The pre-apply human list can be stale (a rewritten model, a doc now out of sync): report the upgraded
|
|
1443
|
+
// repo's, computed before the `Em version:` stamp so transition-only notes still show.
|
|
1444
|
+
report.human = detectHumanItems(ctx);
|
|
936
1445
|
const loaded = loadStateFile(ctx.baseDir);
|
|
937
1446
|
let emVersionCommit = null;
|
|
938
1447
|
if (loaded.ok) {
|