@friedbotstudio/create-baseline 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -10
- package/bin/cli.js +19 -13
- package/obj/template/.claude/commands/init-project-doctor.md +74 -0
- package/obj/template/.claude/hooks/lib/resume_writer.py +14 -1
- package/obj/template/.claude/hooks/memory_session_start.sh +24 -0
- package/obj/template/.claude/hooks/track_guard.sh +11 -1
- package/obj/template/.claude/manifest.json +31 -99
- package/obj/template/.claude/schemas/workflow-track.v1.json +64 -0
- package/obj/template/.claude/skills/audit-baseline/audit.sh +2 -2
- package/obj/template/.claude/skills/chore/SKILL.md +2 -2
- package/obj/template/.claude/skills/harness/SKILL.md +15 -6
- package/obj/template/.claude/skills/intake/SKILL.md +1 -1
- package/obj/template/.claude/skills/swarm-plan/SKILL.md +2 -0
- package/obj/template/.claude/skills/tdd/SKILL.md +2 -2
- package/obj/template/.claude/skills/triage/SKILL.md +29 -6
- package/obj/template/.claude/skills/triage/seed-tasklist.mjs +107 -0
- package/obj/template/.claude/skills/upgrade-project/SKILL.md +18 -7
- package/obj/template/.claude/workflows.jsonl +6 -0
- package/obj/template/CLAUDE.md +8 -14
- package/obj/template/docs/init/seed.md +148 -3
- package/package.json +1 -1
- package/src/.claude/workflows.template.jsonl +6 -0
- package/src/CLAUDE.template.md +8 -14
- package/src/cli/install.js +5 -1
- package/src/cli/merge.js +42 -5
- package/src/cli/track-tasklist-materializer.js +223 -0
- package/src/cli/tui/upgrade.js +30 -41
- package/src/cli/upgrade-tiers.js +42 -4
- package/src/cli/workflow-migrator.js +40 -0
- package/src/cli/workflows-validator-invariants.js +417 -0
- package/src/cli/workflows-validator-predicates.js +19 -0
- package/src/cli/workflows-validator.js +156 -0
- package/src/seed.template.md +148 -3
- package/obj/template/.claude/skills/google-analytics/SKILL.md +0 -129
- package/obj/template/.claude/skills/google-analytics/references/audiences.md +0 -389
- package/obj/template/.claude/skills/google-analytics/references/bigquery.md +0 -470
- package/obj/template/.claude/skills/google-analytics/references/custom-dimensions.md +0 -355
- package/obj/template/.claude/skills/google-analytics/references/custom-events.md +0 -383
- package/obj/template/.claude/skills/google-analytics/references/data-management.md +0 -416
- package/obj/template/.claude/skills/google-analytics/references/debugview.md +0 -364
- package/obj/template/.claude/skills/google-analytics/references/events-fundamentals.md +0 -398
- package/obj/template/.claude/skills/google-analytics/references/gtag.md +0 -502
- package/obj/template/.claude/skills/google-analytics/references/gtm-integration.md +0 -483
- package/obj/template/.claude/skills/google-analytics/references/measurement-protocol.md +0 -519
- package/obj/template/.claude/skills/google-analytics/references/privacy.md +0 -441
- package/obj/template/.claude/skills/google-analytics/references/recommended-events.md +0 -464
- package/obj/template/.claude/skills/google-analytics/references/reporting.md +0 -397
- package/obj/template/.claude/skills/google-analytics/references/setup.md +0 -344
- package/obj/template/.claude/skills/google-analytics/references/user-tracking.md +0 -417
- package/obj/template/.claude/skills/optimize-seo/SKILL.md +0 -313
- package/obj/template/.claude/skills/optimize-seo/scripts/pagespeed.mjs +0 -197
- package/obj/template/.claude/skills/pagespeed-insights/LICENSE.md +0 -37
- package/obj/template/.claude/skills/pagespeed-insights/SKILL.md +0 -446
- package/obj/template/.claude/skills/pagespeed-insights/reference.md +0 -50
- package/src/cli/diff-render.js +0 -54
package/src/cli/tui/upgrade.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// Plan/apply split:
|
|
3
3
|
// 1. detect pending semantic-merge stage (idempotency short-circuit, AC-007)
|
|
4
4
|
// 2. dry-run threeWayMerge → enumerate SKIP_CUSTOMIZED conflicts (tier-1 only)
|
|
5
|
-
// 3. prompt the user once per tier-1 conflict
|
|
5
|
+
// 3. prompt the user once per tier-1 conflict: Keep your version / Use new
|
|
6
|
+
// baseline / Merge / Abort. The Merge pick stages incoming bytes for
|
|
7
|
+
// /upgrade-project to reconcile (tier1-merge-option spec).
|
|
6
8
|
// 4. on cancel/abort: bail before any write
|
|
7
9
|
// 5. on resolve: real threeWayMerge with onSkipCustomized backed by the Map.
|
|
8
10
|
// Tier-2 MECHANICAL and tier-3 SEMANTIC files are NOT prompted — they're
|
|
@@ -12,11 +14,10 @@ import * as clackModule from '@clack/prompts';
|
|
|
12
14
|
import { existsSync } from 'node:fs';
|
|
13
15
|
import { readdir, readFile } from 'node:fs/promises';
|
|
14
16
|
import { join, relative, sep } from 'node:path';
|
|
15
|
-
import { threeWayMerge, ACTION_KINDS } from '../merge.js';
|
|
17
|
+
import { threeWayMerge, ACTION_KINDS, ACTION_LABELS, ACTION_LABEL_WIDTH } from '../merge.js';
|
|
16
18
|
import { loadManifest, buildManifestFromDir } from '../manifest.js';
|
|
17
19
|
import { COPY_EXCLUDE } from '../install.js';
|
|
18
|
-
import { findPendingStage } from '../upgrade-tiers.js';
|
|
19
|
-
import { renderUnifiedDiff } from '../diff-render.js';
|
|
20
|
+
import { findPendingStage, formatStageTimestamp } from '../upgrade-tiers.js';
|
|
20
21
|
import { renderBrandStrip } from './splash.js';
|
|
21
22
|
|
|
22
23
|
const SUCCESS = 0;
|
|
@@ -29,12 +30,10 @@ const ERR_SEMANTIC_STAGED = 5;
|
|
|
29
30
|
const CHOICE_OPTIONS = [
|
|
30
31
|
{ value: 'keep-mine', label: 'Keep your version', hint: 'preserve target file as-is' },
|
|
31
32
|
{ value: 'take-theirs', label: 'Use new baseline', hint: 'overwrite with new template' },
|
|
32
|
-
{ value: '
|
|
33
|
+
{ value: 'merge', label: 'Merge', hint: 'stage incoming bytes for /upgrade-project to reconcile' },
|
|
33
34
|
{ value: 'abort', label: 'Abort', hint: 'exit without changes' },
|
|
34
35
|
];
|
|
35
36
|
|
|
36
|
-
const SHOW_DIFF_CONSECUTIVE_CAP = 2;
|
|
37
|
-
|
|
38
37
|
export async function run({ target, opts = {}, prompts = clackModule } = {}) {
|
|
39
38
|
if (!target || typeof target !== 'string') {
|
|
40
39
|
throw new Error('tui.upgrade.run requires a non-empty string target');
|
|
@@ -58,14 +57,14 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
|
|
|
58
57
|
|
|
59
58
|
const { oldManifest, newManifest } = await loadManifests(opts.templateDir, manifestPath);
|
|
60
59
|
if (isLegacyManifest(oldManifest)) {
|
|
61
|
-
prompts.log.warn(
|
|
60
|
+
prompts.log.warn("Your previous install predates version-tracked manifests, so this upgrade can't perform automatic three-way merges on customized files. You'll be prompted to keep your version or take the new baseline for each customized file. To enable three-way merges next time, re-install with the latest baseline.");
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
const dryReport = await threeWayMerge(opts.templateDir, target, oldManifest, newManifest, { dryRun: true });
|
|
65
64
|
const conflicts = dryReport.actions.filter((a) => a.kind === ACTION_KINDS.SKIP_CUSTOMIZED);
|
|
66
65
|
|
|
67
66
|
const choices = new Map();
|
|
68
|
-
const aborted = await collectUserChoices(prompts, conflicts,
|
|
67
|
+
const aborted = await collectUserChoices(prompts, conflicts, choices);
|
|
69
68
|
if (aborted) {
|
|
70
69
|
prompts.cancel('Upgrade aborted; tree unchanged.');
|
|
71
70
|
return ERR_ABORT;
|
|
@@ -73,7 +72,8 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
|
|
|
73
72
|
|
|
74
73
|
if (opts.dryRun) {
|
|
75
74
|
for (const action of dryReport.actions) {
|
|
76
|
-
|
|
75
|
+
const label = ACTION_LABELS[action.kind] ?? action.kind;
|
|
76
|
+
prompts.log.info(`${label.padEnd(ACTION_LABEL_WIDTH)} ${action.path}`);
|
|
77
77
|
}
|
|
78
78
|
prompts.outro('Dry run complete; no changes written.');
|
|
79
79
|
return SUCCESS;
|
|
@@ -84,7 +84,8 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
|
|
|
84
84
|
|
|
85
85
|
for (const action of finalReport.actions) {
|
|
86
86
|
if (isReportableAction(action.kind)) {
|
|
87
|
-
|
|
87
|
+
const label = ACTION_LABELS[action.kind] ?? action.kind;
|
|
88
|
+
prompts.log.info(`${label.padEnd(ACTION_LABEL_WIDTH)} ${action.path}`);
|
|
88
89
|
}
|
|
89
90
|
if (action.kind === ACTION_KINDS.MECHANICAL_MERGE_CONFLICTED) {
|
|
90
91
|
prompts.log.warn(`Merged with conflicts — resolve in ${action.path}`);
|
|
@@ -93,19 +94,23 @@ export async function run({ target, opts = {}, prompts = clackModule } = {}) {
|
|
|
93
94
|
|
|
94
95
|
const stagedCount = finalReport.actions.filter((a) => a.kind === ACTION_KINDS.SEMANTIC_MERGE_STAGED).length;
|
|
95
96
|
if (stagedCount > 0) {
|
|
96
|
-
prompts.log.info(`${stagedCount} file(s)
|
|
97
|
+
prompts.log.info(`${stagedCount} file(s) staged. Open Claude Code and run /upgrade-project to reconcile.`);
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
const applied = finalReport.actions.filter((a) => isApplied(a.kind)).length;
|
|
100
101
|
const skipped = finalReport.actions.filter((a) => a.kind === ACTION_KINDS.SKIP_CUSTOMIZED).length;
|
|
101
|
-
prompts.outro(
|
|
102
|
+
prompts.outro(
|
|
103
|
+
skipped === 0
|
|
104
|
+
? `Applied ${applied} update(s).`
|
|
105
|
+
: `Applied ${applied} update(s); kept your version on ${skipped} customized file(s). Re-run \`create-baseline upgrade\` if you want to revisit those choices.`,
|
|
106
|
+
);
|
|
102
107
|
return mapExitCode(finalReport.exitCode);
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
function reportPendingStage(prompts, pending) {
|
|
106
111
|
const fileLines = pending.files.map((f) => ` - ${f}`).join('\n');
|
|
107
|
-
prompts.log.warn(`
|
|
108
|
-
prompts.outro('No new work;
|
|
112
|
+
prompts.log.warn(`A previous upgrade staged ${pending.files.length} file(s) for Claude Code review (staged ${formatStageTimestamp(pending.stage_ts)}):\n${fileLines}\nOpen Claude Code and run /upgrade-project to reconcile.`);
|
|
113
|
+
prompts.outro('No new work; previous staged files still need reconciliation.');
|
|
109
114
|
return ERR_SEMANTIC_STAGED;
|
|
110
115
|
}
|
|
111
116
|
|
|
@@ -115,38 +120,22 @@ function isLegacyManifest(m) {
|
|
|
115
120
|
return typeof m.baseline_version !== 'string';
|
|
116
121
|
}
|
|
117
122
|
|
|
118
|
-
async function collectUserChoices(prompts, conflicts,
|
|
123
|
+
async function collectUserChoices(prompts, conflicts, choices) {
|
|
119
124
|
for (const conflict of conflicts) {
|
|
120
|
-
const choice = await pickForFile(prompts, conflict.path
|
|
125
|
+
const choice = await pickForFile(prompts, conflict.path);
|
|
121
126
|
if (choice === 'abort') return true;
|
|
122
|
-
|
|
127
|
+
choices.set(conflict.path, choice);
|
|
123
128
|
}
|
|
124
129
|
return false;
|
|
125
130
|
}
|
|
126
131
|
|
|
127
|
-
async function pickForFile(prompts, rel
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (prompts.isCancel(choice)) return 'abort';
|
|
135
|
-
if (choice !== 'show-diff') return choice;
|
|
136
|
-
await renderConflictDiff(prompts, rel, templateDir, target);
|
|
137
|
-
consecutiveShowDiff++;
|
|
138
|
-
if (consecutiveShowDiff >= SHOW_DIFF_CONSECUTIVE_CAP) {
|
|
139
|
-
prompts.log.info(`Show-diff picked ${SHOW_DIFF_CONSECUTIVE_CAP} times for ${rel}; falling through (keeping your version). Re-run if you want to choose differently.`);
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
async function renderConflictDiff(prompts, rel, templateDir, target) {
|
|
146
|
-
const localBytes = await readFile(join(target, rel), 'utf8');
|
|
147
|
-
const incomingBytes = await readFile(join(templateDir, rel), 'utf8');
|
|
148
|
-
const diff = renderUnifiedDiff(localBytes, incomingBytes, { colorize: process.stdout.isTTY === true });
|
|
149
|
-
prompts.log.info(`Diff for ${rel} (local → incoming):\n${diff}`);
|
|
132
|
+
async function pickForFile(prompts, rel) {
|
|
133
|
+
const choice = await prompts.select({
|
|
134
|
+
message: `${rel} has been customized — choose:`,
|
|
135
|
+
options: CHOICE_OPTIONS,
|
|
136
|
+
});
|
|
137
|
+
if (prompts.isCancel(choice)) return 'abort';
|
|
138
|
+
return choice;
|
|
150
139
|
}
|
|
151
140
|
|
|
152
141
|
function isReportableAction(kind) {
|
package/src/cli/upgrade-tiers.js
CHANGED
|
@@ -41,6 +41,28 @@ export async function resolveBase(rel, baseline_version, target, opts = {}) {
|
|
|
41
41
|
return fetched;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// Returns true when resolveBase would succeed for this file without throwing
|
|
45
|
+
// NoBaseError. Used by merge.js:dispatchCustomized in dry-run mode so the TUI
|
|
46
|
+
// surfaces files whose BASE is unrecoverable as conflicts (and prompts the
|
|
47
|
+
// user) instead of optimistically classifying them as tier-2/3 merge candidates
|
|
48
|
+
// that will silently fall back to keep-mine at real-run time.
|
|
49
|
+
export function canRecoverBase(rel, baseline_version, target) {
|
|
50
|
+
const cachePath = join(target, '.claude/.baseline-prior', rel);
|
|
51
|
+
if (existsSync(cachePath)) return true;
|
|
52
|
+
return Boolean(baseline_version);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Render a stage_ts (the `stageTimestamp` format: ISO 8601 with every `:` and
|
|
56
|
+
// `.` replaced by `-`, e.g. "2026-05-20T14-49-00-000Z") as a human-readable
|
|
57
|
+
// "YYYY-MM-DD HH:MM UTC". Returns the input unchanged when the pattern doesn't
|
|
58
|
+
// match so we never silently corrupt an unexpected value.
|
|
59
|
+
export function formatStageTimestamp(ts) {
|
|
60
|
+
if (typeof ts !== 'string') return String(ts);
|
|
61
|
+
const m = ts.match(/^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-\d{2}-\d{3}Z$/);
|
|
62
|
+
if (!m) return ts;
|
|
63
|
+
return `${m[1]} ${m[2]}:${m[3]} UTC`;
|
|
64
|
+
}
|
|
65
|
+
|
|
44
66
|
export async function findPendingStage(target) {
|
|
45
67
|
const stageRoot = join(target, '.claude/state/upgrade');
|
|
46
68
|
if (!existsSync(stageRoot)) return null;
|
|
@@ -64,14 +86,30 @@ export async function dispatchByTier(rel, tier, ctx) {
|
|
|
64
86
|
}
|
|
65
87
|
|
|
66
88
|
export async function writeStage(ctx, rel, baseBuf, incomingBuf, localBuf) {
|
|
67
|
-
|
|
68
|
-
const stageDir = join(ctx.target, '.claude/state/upgrade', ctx.stageRunTs);
|
|
69
|
-
await mkdir(stageDir, { recursive: true });
|
|
89
|
+
const stageDir = await ensureStageDir(ctx);
|
|
70
90
|
await writeStageArtifact(stageDir, `${rel}.baseline-base`, baseBuf);
|
|
71
91
|
await writeStageArtifact(stageDir, `${rel}.baseline-incoming`, incomingBuf);
|
|
72
92
|
await appendToStageManifest(stageDir, ctx, rel, baseBuf, incomingBuf, localBuf);
|
|
73
93
|
}
|
|
74
94
|
|
|
95
|
+
// BASE-less stage writer used by the tier-1 Merge pick (see
|
|
96
|
+
// docs/specs/tier1-merge-option.md §Behavior #2 + design pick 1A). Unlike
|
|
97
|
+
// writeStage, no BASE artifact is written and the manifest entry carries
|
|
98
|
+
// base_sha256: null — the discriminator /upgrade-project reads to route to
|
|
99
|
+
// two-way reconciliation.
|
|
100
|
+
export async function writeStageBaseless(ctx, rel, incomingBuf, localBuf) {
|
|
101
|
+
const stageDir = await ensureStageDir(ctx);
|
|
102
|
+
await writeStageArtifact(stageDir, `${rel}.baseline-incoming`, incomingBuf);
|
|
103
|
+
await appendToStageManifest(stageDir, ctx, rel, null, incomingBuf, localBuf);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function ensureStageDir(ctx) {
|
|
107
|
+
if (!ctx.stageRunTs) ctx.stageRunTs = stageTimestamp();
|
|
108
|
+
const stageDir = join(ctx.target, '.claude/state/upgrade', ctx.stageRunTs);
|
|
109
|
+
await mkdir(stageDir, { recursive: true });
|
|
110
|
+
return stageDir;
|
|
111
|
+
}
|
|
112
|
+
|
|
75
113
|
// --- foundation helpers ---
|
|
76
114
|
|
|
77
115
|
function sha256(buf) {
|
|
@@ -210,7 +248,7 @@ async function appendToStageManifest(stageDir, ctx, rel, baseBuf, incomingBuf, l
|
|
|
210
248
|
: newStageManifest(ctx);
|
|
211
249
|
manifest.files.push({
|
|
212
250
|
rel,
|
|
213
|
-
base_sha256: sha256(baseBuf),
|
|
251
|
+
base_sha256: baseBuf === null ? null : sha256(baseBuf),
|
|
214
252
|
incoming_sha256: sha256(incomingBuf),
|
|
215
253
|
local_sha256: sha256(localBuf),
|
|
216
254
|
status: 'PENDING',
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Foundation — one-shot migrator that rewrites a pre-§18 `workflow.json`
|
|
2
|
+
// (entry_phase set, no track_id) into the post-§18 shape (track_id, plus
|
|
3
|
+
// skipped_alternates[]) in place. Idempotent on already-post-§18 input.
|
|
4
|
+
// Throws a named error when entry_phase is not in the canonical map.
|
|
5
|
+
|
|
6
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
7
|
+
|
|
8
|
+
export const ENTRY_PHASE_TO_TRACK_ID = Object.freeze({
|
|
9
|
+
intake: 'intake-full',
|
|
10
|
+
spec: 'spec-entry',
|
|
11
|
+
tdd: 'tdd-quickfix',
|
|
12
|
+
chore: 'chore',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export async function migrateWorkflowJsonInPlace(filePath) {
|
|
16
|
+
const text = await readFile(filePath, 'utf8');
|
|
17
|
+
const data = JSON.parse(text);
|
|
18
|
+
if ('track_id' in data && !('entry_phase' in data)) {
|
|
19
|
+
return { migrated: false, reason: 'already post-§18' };
|
|
20
|
+
}
|
|
21
|
+
if (!('entry_phase' in data)) {
|
|
22
|
+
return { migrated: false, reason: 'no entry_phase and no track_id; cannot determine shape' };
|
|
23
|
+
}
|
|
24
|
+
const entryPhase = data.entry_phase;
|
|
25
|
+
const trackId = ENTRY_PHASE_TO_TRACK_ID[entryPhase];
|
|
26
|
+
if (!trackId) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Pre-§18 workflow.json has unmapped entry_phase='${entryPhase}'. ` +
|
|
29
|
+
`Canonical map covers ${Object.keys(ENTRY_PHASE_TO_TRACK_ID).join(', ')}. ` +
|
|
30
|
+
`Cannot migrate; run /triage to restart this workflow.`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const migrated = { ...data };
|
|
34
|
+
migrated.track_id = trackId;
|
|
35
|
+
migrated.skipped_alternates = Array.isArray(data.skipped_alternates) ? data.skipped_alternates : [];
|
|
36
|
+
migrated.updated_at = Math.floor(Date.now() / 1000);
|
|
37
|
+
delete migrated.entry_phase;
|
|
38
|
+
await writeFile(filePath, JSON.stringify(migrated, null, 2) + '\n');
|
|
39
|
+
return { migrated: true, track_id: trackId };
|
|
40
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// Domain — Article IV invariant checks (I1..I11). Each `check*` function
|
|
2
|
+
// returns an array of named-error objects; empty array means the invariant
|
|
3
|
+
// holds. `checkAllInvariants` runs them in order and returns the union.
|
|
4
|
+
//
|
|
5
|
+
// Named-error shape:
|
|
6
|
+
// { kind: 'invariant_iN', track_id?, node_id?, message, ...details }
|
|
7
|
+
|
|
8
|
+
import { isKnownPredicate } from './workflows-validator-predicates.js';
|
|
9
|
+
|
|
10
|
+
// ---------- I1 ----------
|
|
11
|
+
|
|
12
|
+
export function checkI1_uniqueTrackIds(tracks) {
|
|
13
|
+
const seen = new Map();
|
|
14
|
+
const errors = [];
|
|
15
|
+
for (let i = 0; i < tracks.length; i++) {
|
|
16
|
+
const t = tracks[i];
|
|
17
|
+
if (seen.has(t.track_id)) {
|
|
18
|
+
errors.push({
|
|
19
|
+
kind: 'invariant_i1',
|
|
20
|
+
track_id: t.track_id,
|
|
21
|
+
first_line: seen.get(t.track_id) + 1,
|
|
22
|
+
second_line: i + 1,
|
|
23
|
+
message: `Duplicate track_id '${t.track_id}' at index ${i + 1} (first seen at index ${seen.get(t.track_id) + 1}).`,
|
|
24
|
+
});
|
|
25
|
+
} else {
|
|
26
|
+
seen.set(t.track_id, i);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return errors;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------- I2 ----------
|
|
33
|
+
|
|
34
|
+
export function checkI2_uniqueNodeIdsWithinTrack(tracks) {
|
|
35
|
+
const errors = [];
|
|
36
|
+
for (const t of tracks) {
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
for (const node of t.nodes) {
|
|
39
|
+
if (seen.has(node.id)) {
|
|
40
|
+
errors.push({
|
|
41
|
+
kind: 'invariant_i2',
|
|
42
|
+
track_id: t.track_id,
|
|
43
|
+
node_id: node.id,
|
|
44
|
+
message: `Track '${t.track_id}' has duplicate node id '${node.id}'.`,
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
seen.add(node.id);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return errors;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------- I3 ----------
|
|
55
|
+
|
|
56
|
+
export function checkI3_skillOrSubTrackXor(tracks) {
|
|
57
|
+
const errors = [];
|
|
58
|
+
for (const t of tracks) {
|
|
59
|
+
for (const node of t.nodes) {
|
|
60
|
+
if (node.type === 'selector') {
|
|
61
|
+
if (!Array.isArray(node.alternates) || node.alternates.length === 0) {
|
|
62
|
+
errors.push({
|
|
63
|
+
kind: 'invariant_i3',
|
|
64
|
+
track_id: t.track_id,
|
|
65
|
+
node_id: node.id,
|
|
66
|
+
message: `Selector node '${node.id}' in track '${t.track_id}' has empty alternates[]. Selector nodes require non-empty alternates.`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
const hasSkill = typeof node.skill === 'string' && node.skill.length > 0;
|
|
71
|
+
const hasSubTrack = typeof node.sub_track === 'string' && node.sub_track.length > 0;
|
|
72
|
+
if (hasSkill && hasSubTrack) {
|
|
73
|
+
errors.push({
|
|
74
|
+
kind: 'invariant_i3',
|
|
75
|
+
track_id: t.track_id,
|
|
76
|
+
node_id: node.id,
|
|
77
|
+
message: `Task node '${node.id}' in track '${t.track_id}' has BOTH skill and sub_track set. Exactly one of {skill, sub_track} is required.`,
|
|
78
|
+
});
|
|
79
|
+
} else if (!hasSkill && !hasSubTrack) {
|
|
80
|
+
errors.push({
|
|
81
|
+
kind: 'invariant_i3',
|
|
82
|
+
track_id: t.track_id,
|
|
83
|
+
node_id: node.id,
|
|
84
|
+
message: `Task node '${node.id}' in track '${t.track_id}' has NEITHER skill nor sub_track set. Exactly one is required.`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return errors;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------- I4 ----------
|
|
94
|
+
|
|
95
|
+
export function checkI4_edgeResolution(tracks) {
|
|
96
|
+
const errors = [];
|
|
97
|
+
for (const t of tracks) {
|
|
98
|
+
const nodeIds = new Set(t.nodes.map((n) => n.id));
|
|
99
|
+
for (const node of t.nodes) {
|
|
100
|
+
for (const dep of node.depends_on || []) {
|
|
101
|
+
if (!nodeIds.has(dep)) {
|
|
102
|
+
errors.push({
|
|
103
|
+
kind: 'invariant_i4',
|
|
104
|
+
track_id: t.track_id,
|
|
105
|
+
node_id: node.id,
|
|
106
|
+
message: `Track '${t.track_id}' node '${node.id}' depends_on '${dep}' which does not exist in the track. (I4: edge resolution)`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const blk of node.blocks || []) {
|
|
111
|
+
if (!nodeIds.has(blk)) {
|
|
112
|
+
errors.push({
|
|
113
|
+
kind: 'invariant_i4',
|
|
114
|
+
track_id: t.track_id,
|
|
115
|
+
node_id: node.id,
|
|
116
|
+
message: `Track '${t.track_id}' node '${node.id}' blocks '${blk}' which does not exist in the track. (I4: edge resolution)`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return errors;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------- I5 ----------
|
|
126
|
+
|
|
127
|
+
export function checkI5_dagAcyclic(tracks) {
|
|
128
|
+
const errors = [];
|
|
129
|
+
for (const t of tracks) {
|
|
130
|
+
const cycle = detectCycle(t.nodes);
|
|
131
|
+
if (cycle) {
|
|
132
|
+
errors.push({
|
|
133
|
+
kind: 'invariant_i5',
|
|
134
|
+
track_id: t.track_id,
|
|
135
|
+
cycle,
|
|
136
|
+
message: `Track '${t.track_id}' has a cycle in its dependency DAG: ${cycle.join(' -> ')}.`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return errors;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function detectCycle(nodes) {
|
|
144
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
145
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
146
|
+
const color = new Map(nodes.map((n) => [n.id, WHITE]));
|
|
147
|
+
const stack = [];
|
|
148
|
+
function dfs(id) {
|
|
149
|
+
color.set(id, GRAY);
|
|
150
|
+
stack.push(id);
|
|
151
|
+
const node = byId.get(id);
|
|
152
|
+
for (const dep of node?.depends_on || []) {
|
|
153
|
+
if (!byId.has(dep)) continue;
|
|
154
|
+
const c = color.get(dep);
|
|
155
|
+
if (c === GRAY) {
|
|
156
|
+
const idx = stack.indexOf(dep);
|
|
157
|
+
return stack.slice(idx).concat(dep);
|
|
158
|
+
}
|
|
159
|
+
if (c === WHITE) {
|
|
160
|
+
const found = dfs(dep);
|
|
161
|
+
if (found) return found;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
stack.pop();
|
|
165
|
+
color.set(id, BLACK);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
for (const n of nodes) {
|
|
169
|
+
if (color.get(n.id) === WHITE) {
|
|
170
|
+
const found = dfs(n.id);
|
|
171
|
+
if (found) return found;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------- I6 ----------
|
|
178
|
+
|
|
179
|
+
export function checkI6_commitsTrackHasGrantCommit(tracks) {
|
|
180
|
+
const errors = [];
|
|
181
|
+
for (const t of tracks) {
|
|
182
|
+
if (!Array.isArray(t.invariants) || !t.invariants.includes('commits')) continue;
|
|
183
|
+
const commitNode = t.nodes.find((n) => n.skill === 'commit');
|
|
184
|
+
if (!commitNode) {
|
|
185
|
+
errors.push({
|
|
186
|
+
kind: 'invariant_i6',
|
|
187
|
+
track_id: t.track_id,
|
|
188
|
+
message: `Track '${t.track_id}' declares 'commits' invariant but contains no node with skill='commit'.`,
|
|
189
|
+
});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const grantCommitNode = t.nodes.find(
|
|
193
|
+
(n) => n.needs_user === true && (n.skill === 'grant-commit' || n.id === 'grant-commit')
|
|
194
|
+
);
|
|
195
|
+
if (!grantCommitNode) {
|
|
196
|
+
errors.push({
|
|
197
|
+
kind: 'invariant_i6',
|
|
198
|
+
track_id: t.track_id,
|
|
199
|
+
message: `Track '${t.track_id}' declares 'commits' invariant but has no needs_user 'grant-commit' node before commit.`,
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!nodeOrderedBefore(t, grantCommitNode.id, commitNode.id)) {
|
|
204
|
+
errors.push({
|
|
205
|
+
kind: 'invariant_i6',
|
|
206
|
+
track_id: t.track_id,
|
|
207
|
+
message: `Track '${t.track_id}' grant-commit node is not ordered before commit in the dependency DAG.`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return errors;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function nodeOrderedBefore(track, predecessorId, successorId) {
|
|
215
|
+
const byId = new Map(track.nodes.map((n) => [n.id, n]));
|
|
216
|
+
const visited = new Set();
|
|
217
|
+
function reaches(fromId) {
|
|
218
|
+
if (fromId === successorId) return true;
|
|
219
|
+
if (visited.has(fromId)) return false;
|
|
220
|
+
visited.add(fromId);
|
|
221
|
+
const node = byId.get(fromId);
|
|
222
|
+
for (const blocked of node?.blocks || []) {
|
|
223
|
+
if (reaches(blocked)) return true;
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
return reaches(predecessorId);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------- I7 ----------
|
|
231
|
+
|
|
232
|
+
export function checkI7_subTrackResolves(tracks) {
|
|
233
|
+
const errors = [];
|
|
234
|
+
const trackMap = new Map(tracks.map((t) => [t.track_id, t]));
|
|
235
|
+
for (const t of tracks) {
|
|
236
|
+
for (const node of t.nodes) {
|
|
237
|
+
const subTrackRefs = collectSubTrackRefs(node);
|
|
238
|
+
for (const ref of subTrackRefs) {
|
|
239
|
+
const target = trackMap.get(ref);
|
|
240
|
+
if (!target) {
|
|
241
|
+
errors.push({
|
|
242
|
+
kind: 'invariant_i7',
|
|
243
|
+
track_id: t.track_id,
|
|
244
|
+
node_id: node.id,
|
|
245
|
+
message: `Track '${t.track_id}' node '${node.id}' references sub_track '${ref}' which does not exist.`,
|
|
246
|
+
});
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (target.selectable === true) {
|
|
250
|
+
errors.push({
|
|
251
|
+
kind: 'invariant_i7',
|
|
252
|
+
track_id: t.track_id,
|
|
253
|
+
node_id: node.id,
|
|
254
|
+
message: `Track '${t.track_id}' node '${node.id}' references sub_track '${ref}' whose selectable=true. Sub-tracks must have selectable=false.`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return errors;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function collectSubTrackRefs(node) {
|
|
264
|
+
const refs = [];
|
|
265
|
+
if (node.sub_track) refs.push(node.sub_track);
|
|
266
|
+
if (Array.isArray(node.alternates)) {
|
|
267
|
+
for (const alt of node.alternates) {
|
|
268
|
+
if (alt.sub_track) refs.push(alt.sub_track);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return refs;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------- I8 ----------
|
|
275
|
+
|
|
276
|
+
export function checkI8_skillResolves(tracks, { knownSkills }) {
|
|
277
|
+
const errors = [];
|
|
278
|
+
for (const t of tracks) {
|
|
279
|
+
for (const node of t.nodes) {
|
|
280
|
+
const skillRefs = collectSkillRefs(node);
|
|
281
|
+
for (const skill of skillRefs) {
|
|
282
|
+
if (!knownSkills.has(skill)) {
|
|
283
|
+
errors.push({
|
|
284
|
+
kind: 'invariant_i8',
|
|
285
|
+
track_id: t.track_id,
|
|
286
|
+
node_id: node.id,
|
|
287
|
+
message: `Track '${t.track_id}' node '${node.id}' references skill '${skill}' which does not exist on disk.`,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return errors;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function collectSkillRefs(node) {
|
|
297
|
+
const refs = [];
|
|
298
|
+
if (node.skill) refs.push(node.skill);
|
|
299
|
+
if (Array.isArray(node.alternates)) {
|
|
300
|
+
for (const alt of node.alternates) {
|
|
301
|
+
if (alt.skill) refs.push(alt.skill);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return refs;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------- I9 ----------
|
|
308
|
+
|
|
309
|
+
export function checkI9_consentGateOrdering(tracks) {
|
|
310
|
+
const errors = [];
|
|
311
|
+
for (const t of tracks) {
|
|
312
|
+
const gates = t.nodes.filter((n) => n.needs_user === true);
|
|
313
|
+
for (const gate of gates) {
|
|
314
|
+
const hasDependents = t.nodes.some((n) =>
|
|
315
|
+
(n.depends_on || []).includes(gate.id)
|
|
316
|
+
);
|
|
317
|
+
if (!hasDependents && gate.id !== lastNodeId(t)) {
|
|
318
|
+
errors.push({
|
|
319
|
+
kind: 'invariant_i9',
|
|
320
|
+
track_id: t.track_id,
|
|
321
|
+
node_id: gate.id,
|
|
322
|
+
message: `Track '${t.track_id}' consent gate '${gate.id}' has no dependent nodes. Consent gates must be followed by at least one dependent unless they terminate the track.`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return errors;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function lastNodeId(track) {
|
|
331
|
+
return track.nodes[track.nodes.length - 1]?.id;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------- I10 ----------
|
|
335
|
+
|
|
336
|
+
export function checkI10_alternatesCongruent(tracks) {
|
|
337
|
+
const errors = [];
|
|
338
|
+
for (const t of tracks) {
|
|
339
|
+
for (const node of t.nodes) {
|
|
340
|
+
if (node.type !== 'selector') continue;
|
|
341
|
+
const alternates = node.alternates || [];
|
|
342
|
+
if (alternates.length < 2) continue;
|
|
343
|
+
const firstShape = describeAlternate(alternates[0]);
|
|
344
|
+
for (let i = 1; i < alternates.length; i++) {
|
|
345
|
+
const otherShape = describeAlternate(alternates[i]);
|
|
346
|
+
if (otherShape !== firstShape) {
|
|
347
|
+
errors.push({
|
|
348
|
+
kind: 'invariant_i10',
|
|
349
|
+
track_id: t.track_id,
|
|
350
|
+
node_id: node.id,
|
|
351
|
+
message: `Selector node '${node.id}' in track '${t.track_id}' has alternates with divergent shapes. Alternates must be interchangeable in the DAG (same skill vs sub_track distribution).`,
|
|
352
|
+
});
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return errors;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function describeAlternate(alt) {
|
|
362
|
+
return JSON.stringify({
|
|
363
|
+
hasSubTrack: !!alt.sub_track,
|
|
364
|
+
hasSkill: !!alt.skill,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---------- I11 ----------
|
|
369
|
+
|
|
370
|
+
export function checkI11_predicateNamesResolve(tracks) {
|
|
371
|
+
const errors = [];
|
|
372
|
+
for (const t of tracks) {
|
|
373
|
+
for (const pred of t.preconditions || []) {
|
|
374
|
+
if (!isKnownPredicate(pred.name)) {
|
|
375
|
+
errors.push({
|
|
376
|
+
kind: 'invariant_i11',
|
|
377
|
+
track_id: t.track_id,
|
|
378
|
+
message: `Track '${t.track_id}' precondition uses unknown predicate '${pred.name}'. Not in v1 vocabulary.`,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
for (const node of t.nodes) {
|
|
383
|
+
if (!Array.isArray(node.alternates)) continue;
|
|
384
|
+
for (const alt of node.alternates) {
|
|
385
|
+
for (const pred of alt.preconditions || []) {
|
|
386
|
+
if (!isKnownPredicate(pred.name)) {
|
|
387
|
+
errors.push({
|
|
388
|
+
kind: 'invariant_i11',
|
|
389
|
+
track_id: t.track_id,
|
|
390
|
+
node_id: node.id,
|
|
391
|
+
message: `Track '${t.track_id}' node '${node.id}' alternate uses unknown predicate '${pred.name}'. Not in v1 vocabulary.`,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return errors;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ---------- Orchestration ----------
|
|
402
|
+
|
|
403
|
+
export function checkAllInvariants(tracks, ctx) {
|
|
404
|
+
return [
|
|
405
|
+
...checkI1_uniqueTrackIds(tracks),
|
|
406
|
+
...checkI2_uniqueNodeIdsWithinTrack(tracks),
|
|
407
|
+
...checkI3_skillOrSubTrackXor(tracks),
|
|
408
|
+
...checkI4_edgeResolution(tracks),
|
|
409
|
+
...checkI5_dagAcyclic(tracks),
|
|
410
|
+
...checkI6_commitsTrackHasGrantCommit(tracks),
|
|
411
|
+
...checkI7_subTrackResolves(tracks),
|
|
412
|
+
...checkI8_skillResolves(tracks, ctx),
|
|
413
|
+
...checkI9_consentGateOrdering(tracks),
|
|
414
|
+
...checkI10_alternatesCongruent(tracks),
|
|
415
|
+
...checkI11_predicateNamesResolve(tracks),
|
|
416
|
+
];
|
|
417
|
+
}
|