@awebai/oats 0.37.0 → 0.38.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/bin/oats.mjs +71 -65
- package/docs/capabilities.md +11 -7
- package/docs/configuration.md +10 -20
- package/docs/design/2026-09-27-team-model-v2.md +1 -1
- package/docs/design/2026-10-02-team-model-3.md +146 -0
- package/docs/design/README.md +5 -1
- package/docs/desktop-cli-api.md +159 -121
- package/docs/first-team.md +14 -7
- package/docs/integrations.md +1 -1
- package/docs/oats-local.schema.json +2 -14
- package/docs/oats-workspace.schema.json +4 -4
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +10 -9
- package/docs/release-notes/v0.38.0.md +128 -0
- package/docs/souls-and-instances.md +4 -3
- package/docs/workspaces.md +122 -77
- package/lib/instance-inspect.mjs +18 -15
- package/lib/instance-resolution.mjs +9 -6
- package/lib/resolve.mjs +5 -4
- package/lib/teams-verbs.mjs +44 -70
- package/lib/teams.mjs +151 -112
- package/lib/workspace.mjs +15 -5
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/skills/oats-getting-started/SKILL.md +20 -9
package/lib/resolve.mjs
CHANGED
|
@@ -57,7 +57,7 @@ import { readFileSync } from "node:fs";
|
|
|
57
57
|
import { posix } from "node:path";
|
|
58
58
|
import { oatsError as baseOatsError } from "./errors.mjs";
|
|
59
59
|
import * as defaultRemote from "./remote.mjs";
|
|
60
|
-
import { BY_TEAM_REMOVED, reportRows,
|
|
60
|
+
import { BY_TEAM_REMOVED, reportRows, soulTeams, teamKeyOf, teamModel } from "./teams.mjs";
|
|
61
61
|
import { bindRemote, packageProviding, readPackageManifests, validateLock } from "./packages.mjs";
|
|
62
62
|
import { settingValueProblems } from "./capability-contract.mjs";
|
|
63
63
|
import { memberRowByKey } from "./workspace.mjs";
|
|
@@ -617,9 +617,10 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
|
|
|
617
617
|
assertSoulDiscovered(discovery, soulEntry);
|
|
618
618
|
const definition = isObject(soulEntry.definition) ? soulEntry.definition : {};
|
|
619
619
|
const soul = { name: soulEntry.name, repoKey: soulEntry.repoKey, commit: soulEntry.commit ?? null, path: soulEntry.path ?? null };
|
|
620
|
-
// Team model
|
|
621
|
-
//
|
|
622
|
-
|
|
620
|
+
// Team model 3: the soul's teams HERE (the workspace's teams, defaultTeam and souls:, and the local teams
|
|
621
|
+
// it allows). An undeclared local default (E_TEAM_UNKNOWN) or local teams the workspace does not allow
|
|
622
|
+
// (E_WORKSPACE_SCHEMA local-teams-closed) refuse the soul.
|
|
623
|
+
const soulTeamsHere = soulTeams(teamModel(discovery?.standalone === true ? null : workspace, local, { workspaceKey: discovery?.key ?? null }), teamKeyOf(soulEntry));
|
|
623
624
|
|
|
624
625
|
// Standalone: the soul's own repo only, workspace defaults unknown (decision 10).
|
|
625
626
|
// What the soul turned off (feature desktop-facts): its own `off` over a lower layer, and a `<slot>: none`
|
package/lib/teams-verbs.mjs
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The team model
|
|
3
|
-
* never call a provider. Each reads the deployment's oats-local.yaml and the committed
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The team model 3 verbs (docs/desktop-cli-api.md, `oats teams`, `oats soul teams`): CONFIG ONLY — they
|
|
3
|
+
* never call a provider. Each reads the deployment's oats-local.yaml and the committed workspace file. The
|
|
4
|
+
* mutations (`oats teams add|remove|default`) edit the deployment's LOCAL teams and default, so they run only
|
|
5
|
+
* where the workspace allows local teams (`localTeams: true`, or the standalone view); they rewrite
|
|
6
|
+
* oats-local.yaml in place (comments and every other key kept), after validating the result: the local
|
|
7
|
+
* schema, and no label reference the write would leave unknown. A soul's teams are the workspace's
|
|
8
|
+
* `souls:`, edited by a PR to that file: `oats soul teams` only reads them.
|
|
6
9
|
*/
|
|
7
10
|
import { readFileSync } from "node:fs";
|
|
8
11
|
import YAML from "yaml";
|
|
9
12
|
import { parseConfigData } from "./config-data.mjs";
|
|
10
13
|
import { oatsError } from "./errors.mjs";
|
|
11
14
|
import { validateLocal } from "./workspace.mjs";
|
|
12
|
-
import {
|
|
15
|
+
import { TEAM_ID_RE, localTeamsClosed, soulTeams, teamModel, teamProblems, teamReferences } from "./teams.mjs";
|
|
13
16
|
import { writeFileAtomic } from "./packages.mjs";
|
|
14
17
|
|
|
15
|
-
export const TEAMS_API =
|
|
18
|
+
export const TEAMS_API = 2, SOUL_TEAMS_API = 2;
|
|
16
19
|
const LABEL_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
17
20
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
18
21
|
const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
@@ -21,44 +24,46 @@ function fail(code, message, details) { const e = oatsError(code, message, detai
|
|
|
21
24
|
/** Whether a messaging layer is active in this deployment: the workspace fills the messaging slot. */
|
|
22
25
|
const messagingActive = (workspace) => isObject(workspace?.defaults?.messaging);
|
|
23
26
|
|
|
24
|
-
/**
|
|
27
|
+
/** The default a soul without a `souls:` default gets here: the local one (where local teams are allowed),
|
|
28
|
+
* else the workspace's → DefaultTeam | null. */
|
|
29
|
+
function deploymentDefault(model) {
|
|
30
|
+
const [label, from] = model.localDefault !== null && model.labels.has(model.localDefault) ? [model.localDefault, "deployment"]
|
|
31
|
+
: model.workspaceDefault !== null ? [model.workspaceDefault, "workspace"] : [null, null];
|
|
32
|
+
return label === null || !model.labels.has(label) ? null : { label, team: model.labels.get(label).team, from };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `oats teams --json`. `ctx`: { deployment, local, workspace (the committed file, or null: standalone), workspaceKey,
|
|
36
|
+
* soulKeys (the discovered souls' keys, for the `team-soul-unknown` typo guard; null when not known) }. */
|
|
25
37
|
export function teamsDocument(ctx) {
|
|
26
38
|
const model = teamModel(ctx.workspace, ctx.local, { workspaceKey: ctx.workspaceKey });
|
|
39
|
+
const def = deploymentDefault(model);
|
|
27
40
|
const teams = [...model.labels.values()].sort((a, b) => byCodepoint(a.label, b.label))
|
|
28
|
-
.map((d) => ({ label: d.label, team: d.team, description: d.description, from: d.from, default: d.label ===
|
|
41
|
+
.map((d) => ({ label: d.label, team: d.team, description: d.description, from: d.from, default: d.label === def?.label, at: d.at }));
|
|
29
42
|
return {
|
|
30
|
-
teamsApi: TEAMS_API, deployment: ctx.deployment,
|
|
31
|
-
souls:
|
|
32
|
-
problems: teamProblems(model, { messaging: messagingActive(ctx.workspace) }),
|
|
43
|
+
teamsApi: TEAMS_API, deployment: ctx.deployment, localTeams: model.localTeams, defaultTeam: def, teams,
|
|
44
|
+
souls: structuredClone(model.souls),
|
|
45
|
+
problems: teamProblems(model, { messaging: messagingActive(ctx.workspace), soulKeys: ctx.soulKeys ?? null }),
|
|
33
46
|
};
|
|
34
47
|
}
|
|
35
48
|
|
|
36
|
-
/** `oats soul teams <soul>|'*' --json`: `key` is the soul's key ("*" for
|
|
49
|
+
/** `oats soul teams <soul>|'*' --json`: `key` is the soul's key (teamKeyOf; "*" for the "*" pattern), `soul` its name. */
|
|
37
50
|
export function soulTeamsDocument(ctx, { soul, key }) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
const list = (v) => (Array.isArray(v) ? [...v] : []);
|
|
41
|
-
return {
|
|
42
|
-
soulTeamsApi: SOUL_TEAMS_API, soul, key, defaultTeam: t.defaultTeam, teams: t.teams,
|
|
43
|
-
local: { teams: list(model.souls.teams[key]), default: key === "*" ? null : model.souls.default[key] ?? null },
|
|
44
|
-
all: list(model.souls.teams["*"]),
|
|
45
|
-
};
|
|
51
|
+
const t = soulTeams(teamModel(ctx.workspace, ctx.local, { workspaceKey: ctx.workspaceKey }), key);
|
|
52
|
+
return { soulTeamsApi: SOUL_TEAMS_API, soul, key, match: t.match, defaultMatch: t.defaultMatch, defaultTeam: t.defaultTeam, teams: t.teams };
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
/* ───────────────────────────── writing oats-local.yaml ─────────────────── */
|
|
49
56
|
|
|
50
57
|
const clone = (v) => (v === undefined ? undefined : structuredClone(v));
|
|
51
|
-
/** The
|
|
58
|
+
/** The two team keys of a local value, as plain data. */
|
|
52
59
|
const teamState = (local) => ({
|
|
53
60
|
teams: clone(isObject(local?.teams) ? local.teams : {}),
|
|
54
61
|
defaultTeam: typeof local?.defaultTeam === "string" ? local.defaultTeam : null,
|
|
55
|
-
soulsTeams: clone(isObject(local?.souls?.teams) ? local.souls.teams : {}),
|
|
56
|
-
soulsDefault: clone(isObject(local?.souls?.default) ? local.souls.default : {}),
|
|
57
62
|
});
|
|
58
63
|
const modelOf = (ctx, local) => teamModel(ctx.workspace, local, { workspaceKey: ctx.workspaceKey });
|
|
59
|
-
/** The refusals a state carries (unknown
|
|
64
|
+
/** The refusals a state carries (unknown references), keyed so a write can tell new ones. */
|
|
60
65
|
const refusals = (ctx, local) => new Map(teamProblems(modelOf(ctx, local))
|
|
61
|
-
.filter((p) => p.code === "E_TEAM_UNKNOWN"
|
|
66
|
+
.filter((p) => p.code === "E_TEAM_UNKNOWN").map((p) => [`${p.code} ${p.at}`, p]));
|
|
62
67
|
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
63
68
|
const readLocal = (ctx) => {
|
|
64
69
|
const text = readFileSync(ctx.localPath, "utf8");
|
|
@@ -69,8 +74,7 @@ const readLocal = (ctx) => {
|
|
|
69
74
|
|
|
70
75
|
/**
|
|
71
76
|
* Edit `doc` from `before` to `next` SURGICALLY: only the entries that change are touched, so comments and
|
|
72
|
-
* styles on everything else (inline comments on sibling entries
|
|
73
|
-
* left empty is removed, and so is an empty `souls`.
|
|
77
|
+
* styles on everything else (inline comments on sibling entries) are kept. A map left empty is removed.
|
|
74
78
|
*/
|
|
75
79
|
function applyState(doc, before, next) {
|
|
76
80
|
const map = (path, from, to, entry) => {
|
|
@@ -86,16 +90,6 @@ function applyState(doc, before, next) {
|
|
|
86
90
|
for (const [k, v] of Object.entries(now)) if (!same(was[k], v)) doc.setIn([...path, k], v);
|
|
87
91
|
});
|
|
88
92
|
if (next.defaultTeam !== before.defaultTeam) { if (next.defaultTeam === null) doc.deleteIn(["defaultTeam"]); else doc.setIn(["defaultTeam"], next.defaultTeam); }
|
|
89
|
-
// A soul's list, item by item (its style — flow or block — and the other items' comments stay).
|
|
90
|
-
map(["souls", "teams"], before.soulsTeams, next.soulsTeams, (path, was, now) => {
|
|
91
|
-
const seq = doc.getIn(path, true);
|
|
92
|
-
if (!Array.isArray(was) || !YAML.isSeq(seq)) { doc.setIn(path, now); return; }
|
|
93
|
-
seq.items = seq.items.filter((item) => now.includes(YAML.isScalar(item) ? item.value : item));
|
|
94
|
-
for (const label of now) if (!was.includes(label)) seq.items.push(doc.createNode(label));
|
|
95
|
-
});
|
|
96
|
-
map(["souls", "default"], before.soulsDefault, next.soulsDefault, (path, _was, now) => doc.setIn(path, now));
|
|
97
|
-
const souls = doc.get("souls");
|
|
98
|
-
if (YAML.isMap(souls) && souls.items.length === 0) doc.delete("souls");
|
|
99
93
|
}
|
|
100
94
|
|
|
101
95
|
/**
|
|
@@ -103,14 +97,18 @@ function applyState(doc, before, next) {
|
|
|
103
97
|
* refusal; both come from the file AS IT IS NOW (re-read here, never the command's startup snapshot), so
|
|
104
98
|
* two verbs running together cannot drop each other's change: the write is compare-and-swap — when the
|
|
105
99
|
* file changed after this read, the edit is redone on the new content (a few times, then E_LOCAL_CHANGED).
|
|
106
|
-
* `
|
|
100
|
+
* `verb` names the command and `writes` the keys it adds, for the refusal where the workspace does not
|
|
101
|
+
* allow local teams (local-teams-closed); a verb that only takes local teams away (`writes: null`) runs
|
|
102
|
+
* there too, since it moves the file toward what the workspace allows. → { changed, local } (the value now).
|
|
107
103
|
*/
|
|
108
|
-
function editLocal(ctx,
|
|
104
|
+
function editLocal(ctx, { verb, writes }, mutate) {
|
|
109
105
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
110
106
|
const { text, doc, local: current } = readLocal(ctx);
|
|
107
|
+
const model = modelOf(ctx, current);
|
|
108
|
+
if (model.localTeams === false && writes !== null) throw localTeamsClosed(writes, { verb });
|
|
111
109
|
const before = teamState(current);
|
|
112
110
|
const next = teamState(current);
|
|
113
|
-
mutate(next,
|
|
111
|
+
mutate(next, model);
|
|
114
112
|
if (same(next, before)) return { changed: false, local: current };
|
|
115
113
|
applyState(doc, before, next);
|
|
116
114
|
const out = doc.toString();
|
|
@@ -119,7 +117,6 @@ function editLocal(ctx, mutate, { check } = {}) {
|
|
|
119
117
|
if (problems.length) throw fail("E_WORKSPACE_SCHEMA", `the rewritten oats-local.yaml would be invalid (${problems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}); nothing was written`, { problems });
|
|
120
118
|
const had = refusals(ctx, current);
|
|
121
119
|
for (const [k, p] of refusals(ctx, local)) if (!had.has(k)) throw fail(p.code, `${p.message}; nothing was written`, Object.fromEntries(Object.entries(p).filter(([key]) => !["code", "severity", "message", "fix"].includes(key))));
|
|
122
|
-
if (check) check(local);
|
|
123
120
|
if (readFileSync(ctx.localPath, "utf8") !== text) continue; // changed under us: redo on the new content
|
|
124
121
|
writeFileAtomic(ctx.localPath, out);
|
|
125
122
|
return { changed: true, local };
|
|
@@ -136,7 +133,7 @@ export function teamsAdd(ctx, label, { team, description } = {}) {
|
|
|
136
133
|
assertLabel(label);
|
|
137
134
|
if (typeof team !== "string" || !team.trim()) throw fail("E_BAD_ARGS", "oats teams add needs --team <provider team id>", { label });
|
|
138
135
|
if (!TEAM_ID_RE.test(team.trim())) throw fail("E_BAD_ARGS", `${JSON.stringify(team)} is not a team id: it must match ${TEAM_ID_RE.source} (a letter or digit first; no whitespace or control characters; at most 256 characters)`, { label, team });
|
|
139
|
-
return editLocal(ctx, (s, model) => {
|
|
136
|
+
return editLocal(ctx, { verb: "oats teams add", writes: ["teams"] }, (s, model) => {
|
|
140
137
|
if (model.labels.has(label)) {
|
|
141
138
|
const from = model.labels.get(label).from;
|
|
142
139
|
throw fail("E_TEAM_EXISTS", `team ${label} is already declared (${from === "shared" ? "shared, in oats-workspace.yaml" : "local, in oats-local.yaml"})${from === "local" ? ": remove it first (`oats teams remove`) to redefine it" : ""}`, { label, from });
|
|
@@ -147,9 +144,10 @@ export function teamsAdd(ctx, label, { team, description } = {}) {
|
|
|
147
144
|
});
|
|
148
145
|
}
|
|
149
146
|
|
|
150
|
-
/** `oats teams remove <label>`: a LOCAL team nothing references (no cascade).
|
|
147
|
+
/** `oats teams remove <label>`: a LOCAL team nothing references (no cascade). Allowed where the workspace
|
|
148
|
+
* does not allow local teams: removing them is the last step of committing them there. */
|
|
151
149
|
export function teamsRemove(ctx, label) {
|
|
152
|
-
return editLocal(ctx, (s, model) => {
|
|
150
|
+
return editLocal(ctx, { verb: "oats teams remove", writes: null }, (s, model) => {
|
|
153
151
|
if (!model.local.has(label)) {
|
|
154
152
|
if (model.shared.has(label)) throw fail("E_TEAM_SHARED", `team ${label} is shared (declared in oats-workspace.yaml): it is edited by a PR to that file, never removed here`, { label, at: model.shared.get(label).at });
|
|
155
153
|
throw fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared`, { label });
|
|
@@ -157,39 +155,15 @@ export function teamsRemove(ctx, label) {
|
|
|
157
155
|
// A label also declared in the committed file still resolves after the local copy goes (the
|
|
158
156
|
// collision's fix): its references stay valid, so only a purely local label must be unreferenced.
|
|
159
157
|
const usedBy = model.shared.has(label) ? [] : teamReferences(model, label);
|
|
160
|
-
if (usedBy.length) throw fail("E_TEAM_IN_USE", `team ${label} is still referenced (${usedBy.join(", ")}):
|
|
158
|
+
if (usedBy.length) throw fail("E_TEAM_IN_USE", `team ${label} is still referenced (${usedBy.join(", ")}): choose another default first (\`oats teams default\`)`, { label, usedBy });
|
|
161
159
|
delete s.teams[label];
|
|
162
160
|
});
|
|
163
161
|
}
|
|
164
162
|
|
|
165
163
|
/** `oats teams default <label>`: a label of either file. */
|
|
166
164
|
export function teamsDefault(ctx, label) {
|
|
167
|
-
return editLocal(ctx, (s, model) => {
|
|
165
|
+
return editLocal(ctx, { verb: "oats teams default", writes: ["defaultTeam"] }, (s, model) => {
|
|
168
166
|
if (!model.labels.has(label)) throw fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared: \`oats teams add\` it first`, { label });
|
|
169
167
|
s.defaultTeam = label;
|
|
170
168
|
});
|
|
171
169
|
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* `oats soul teams <soul>|'*' [--add a,b] [--remove a,b] [--default <l> | --clear-default]`.
|
|
175
|
-
* `key` is the soul's key ("*" for every soul). → { changed, local }.
|
|
176
|
-
*/
|
|
177
|
-
export function soulTeamsEdit(ctx, key, { add = [], remove = [], setDefault = null, clearDefault = false } = {}) {
|
|
178
|
-
if (key === "*" && (setDefault !== null || clearDefault)) throw fail("E_BAD_ARGS", "a default is per soul: --default / --clear-default need a soul, not '*' (the deployment's default is `oats teams default`)", { soul: key });
|
|
179
|
-
if (setDefault !== null && clearDefault) throw fail("E_BAD_ARGS", "choose --default <label> or --clear-default, not both");
|
|
180
|
-
// --default must name one of the soul's teams after the write.
|
|
181
|
-
const check = setDefault === null ? undefined : (local) => {
|
|
182
|
-
try { soulTeams(modelOf(ctx, local), key); }
|
|
183
|
-
catch (e) { if (e.code === "E_TEAM_NOT_ELIGIBLE") throw fail("E_TEAM_NOT_ELIGIBLE", `${e.message}; nothing was written`, { soul: key, label: setDefault, at: `${LOCAL_FILE}#/souls/default/${pointerKey(key)}` }); throw e; }
|
|
184
|
-
};
|
|
185
|
-
return editLocal(ctx, (s, model) => {
|
|
186
|
-
for (const label of [...add, ...remove, ...(setDefault !== null ? [setDefault] : [])]) {
|
|
187
|
-
if (!model.labels.has(label)) throw fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared: \`oats teams add\` it first`, { label });
|
|
188
|
-
}
|
|
189
|
-
const list = Array.isArray(s.soulsTeams[key]) ? s.soulsTeams[key] : [];
|
|
190
|
-
const next = [...list.filter((l) => !remove.includes(l)), ...add.filter((l) => !list.includes(l) && !remove.includes(l))].filter((l, i, a) => a.indexOf(l) === i);
|
|
191
|
-
if (next.length) s.soulsTeams[key] = next; else delete s.soulsTeams[key];
|
|
192
|
-
if (clearDefault) delete s.soulsDefault[key];
|
|
193
|
-
if (setDefault !== null) s.soulsDefault[key] = setDefault;
|
|
194
|
-
}, { check });
|
|
195
|
-
}
|
package/lib/teams.mjs
CHANGED
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Team model
|
|
2
|
+
* Team model 3 (docs/design/2026-10-02-team-model-3.md, awebai/oats#484), pure.
|
|
3
3
|
*
|
|
4
4
|
* Where teams live:
|
|
5
|
-
* - the committed oats-workspace.yaml `teams.<label> = { description?, team? }
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* - the committed oats-workspace.yaml: SHARED teams `teams.<label> = { description?, team? }` (a shared
|
|
6
|
+
* team without `team` is declared but not yet created: unmapped); `defaultTeam: <label>`, the
|
|
7
|
+
* workspace's fallback default; `localTeams: true|false` (absent: false), whether deployments may
|
|
8
|
+
* declare their own teams; and `souls: { <pattern>: { default?, teams?: [labels] | "any" } }`;
|
|
9
|
+
* - the deployment's oats-local.yaml, only when the workspace says `localTeams: true` (or there is no
|
|
10
|
+
* workspace file: the standalone view): LOCAL teams `teams.<label> = { team, description? }` and
|
|
11
|
+
* `defaultTeam: <label>`. Otherwise they are refused (E_WORKSPACE_SCHEMA reason local-teams-closed).
|
|
12
|
+
* A soul's key is its qualified name (teamKeyOf): `<package>/<soul>` or `<member>/<soul>`.
|
|
11
13
|
*
|
|
12
|
-
* Resolution:
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Resolution: a `souls:` pattern is the soul's key, then `<member|package>/*`, then "*"; the most specific
|
|
15
|
+
* one that exists gives the soul's teams outright (no merging), and the most specific one that sets a
|
|
16
|
+
* `default` gives its default. Default, in order: that `default` (from "soul"); else the local
|
|
17
|
+
* `defaultTeam` when local teams are allowed ("deployment"); else the workspace's `defaultTeam`
|
|
18
|
+
* ("workspace"); else none. A soul's teams: its default, plus its pattern's `teams` ("any": every shared
|
|
19
|
+
* team), plus every local team when local teams are allowed. A label in both files is
|
|
20
|
+
* `team-label-collision` (the SHARED definition wins). Workspace labels are validated when the file is
|
|
21
|
+
* read (lib/workspace.mjs); a local default no file declares is E_TEAM_UNKNOWN.
|
|
16
22
|
*
|
|
17
|
-
* Rows (docs/desktop-cli-api.md,
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* Team model 3 (0.38.0, awebai/oats#484) moves these choices into the committed workspace file. 0.36.x and
|
|
22
|
-
* 0.37.x validate its keys (lib/workspace.mjs) without applying them, and warn about what 0.38.0 will refuse
|
|
23
|
-
* (migrationProblems: team-model-3-migration).
|
|
23
|
+
* Rows (docs/desktop-cli-api.md, Teams): TeamRow { label, team, default, from: shared|local, via }, `via`
|
|
24
|
+
* ⊂ ["default", "workspace", "local"] in that order (why the soul may join it); the default first, then
|
|
25
|
+
* by label. Reports carry unmapped rows (team null); OATS_TEAMS and instance.json carry mapped rows only.
|
|
26
|
+
* DefaultTeam { label, team, from: soul|deployment|workspace } | null.
|
|
24
27
|
*/
|
|
25
28
|
import { oatsError } from "./errors.mjs";
|
|
26
29
|
|
|
@@ -41,12 +44,16 @@ export const LOCAL_FILE = "oats-local.yaml";
|
|
|
41
44
|
* validates its own id shape. Keep equal to docs/oats-{local,workspace}.schema.json. */
|
|
42
45
|
export const TEAM_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/+-]{0,255}$/;
|
|
43
46
|
/** What 0.30 removed, named by the schema problems that replace them. */
|
|
44
|
-
export const TEAM_MEMBERSHIP_MOVED = "
|
|
47
|
+
export const TEAM_MEMBERSHIP_MOVED = "a soul's teams are decided by souls: in oats-workspace.yaml (team model 3, OATS 0.38.0)";
|
|
45
48
|
export const BY_TEAM_REMOVED = "byTeam was removed in 0.30: a team's provider id is teams.<label>.team (oats-workspace.yaml for a shared team, oats-local.yaml for a local one)";
|
|
46
49
|
|
|
47
50
|
const UNMAPPED_FIX = "its owner runs `oats aweb setup`, then commits the id";
|
|
51
|
+
const CLOSED_FIX = "either (a) add `localTeams: true` to oats-workspace.yaml, or (b) commit the teams and defaultTeam in oats-workspace.yaml, then remove them from oats-local.yaml";
|
|
48
52
|
|
|
49
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* The deployment's teams from the committed workspace and oats-local.yaml (either may be null; a null
|
|
55
|
+
* workspace is the standalone view, which has no workspace rules).
|
|
56
|
+
*/
|
|
50
57
|
export function teamModel(workspace, local, { workspaceKey = null } = {}) {
|
|
51
58
|
const shared = new Map(), localTeams = new Map();
|
|
52
59
|
for (const [label, def] of Object.entries(isObject(workspace?.teams) ? workspace.teams : {})) {
|
|
@@ -56,76 +63,124 @@ export function teamModel(workspace, local, { workspaceKey = null } = {}) {
|
|
|
56
63
|
for (const [label, def] of Object.entries(isObject(local?.teams) ? local.teams : {})) {
|
|
57
64
|
localTeams.set(label, { label, team: str(def?.team), description: str(def?.description), from: "local", at: `${LOCAL_FILE}#/teams/${pointerKey(label)}` });
|
|
58
65
|
}
|
|
59
|
-
const
|
|
60
|
-
const
|
|
66
|
+
const standalone = !isObject(workspace);
|
|
67
|
+
const allowed = standalone || workspace.localTeams === true;
|
|
68
|
+
const localKeys = ["teams", "defaultTeam"].filter((k) => isObject(local) && Object.hasOwn(local, k));
|
|
61
69
|
return {
|
|
62
70
|
shared, local: localTeams,
|
|
63
71
|
labels: new Map([...localTeams, ...shared]), // the committed definition wins a collision
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
},
|
|
72
|
+
// true | false (the workspace's answer), null in the standalone view.
|
|
73
|
+
localTeams: standalone ? null : allowed,
|
|
74
|
+
// The oats-local.yaml keys the workspace refuses (local-teams-closed); [] when allowed.
|
|
75
|
+
closedKeys: allowed ? [] : localKeys,
|
|
76
|
+
localDefault: allowed ? str(local?.defaultTeam) : null,
|
|
77
|
+
workspaceDefault: standalone ? null : str(workspace.defaultTeam),
|
|
78
|
+
souls: !standalone && isObject(workspace.souls) ? workspace.souls : {},
|
|
79
|
+
soulsAt: `${workspaceKey ? `${workspaceKey}:` : ""}oats-workspace.yaml#/souls`,
|
|
73
80
|
};
|
|
74
81
|
}
|
|
75
82
|
|
|
76
|
-
/** The key a soul has in souls.
|
|
83
|
+
/** The key a soul has in oats-local.yaml souls.launch: its bare name, or `<package>/<soul>` for a package soul. */
|
|
77
84
|
export function soulKeyOf(soulEntry) {
|
|
78
85
|
return typeof soulEntry?.package === "string" && typeof soulEntry.qualifiedName === "string" ? soulEntry.qualifiedName : soulEntry.name;
|
|
79
86
|
}
|
|
87
|
+
/** A member's name: the last segment of its repo key, without `.git` (`souls.disabled` and team keys use it). */
|
|
88
|
+
export function memberNameOf(key) {
|
|
89
|
+
return String(key).split("/").filter(Boolean).pop()?.replace(/\.git$/i, "") || String(key);
|
|
90
|
+
}
|
|
91
|
+
/** A soul's key in the workspace's `souls:` (team model 3): `<package>/<soul>` for a package soul,
|
|
92
|
+
* `<member>/<soul>` for any other (its repository's name), as `souls.disabled` qualifies it. */
|
|
93
|
+
export function teamKeyOf(soulEntry) {
|
|
94
|
+
if (typeof soulEntry?.package === "string") return typeof soulEntry.qualifiedName === "string" ? soulEntry.qualifiedName : `${soulEntry.package}/${soulEntry.name}`;
|
|
95
|
+
return `${memberNameOf(soulEntry?.repoKey ?? "")}/${soulEntry?.name}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What a team model v2 oats-local.yaml (`souls.teams`, `souls.default`) gave each soul, as the workspace
|
|
100
|
+
* `souls:` that gives the same (0.38.0's removed-key refusal prints it): "*" keeps `souls.teams["*"]`; each
|
|
101
|
+
* soul named gets its default and "*" ∪ its own list, since patterns never merge. A bare (member) soul
|
|
102
|
+
* name becomes `<member>/<soul>`, for the operator to qualify. → { souls } | null when neither key is set.
|
|
103
|
+
*/
|
|
104
|
+
export function soulsReplacement(local) {
|
|
105
|
+
const teams = isObject(local?.souls?.teams) ? local.souls.teams : null, defaults = isObject(local?.souls?.default) ? local.souls.default : null;
|
|
106
|
+
if (teams === null && defaults === null) return null;
|
|
107
|
+
const star = labelsOf(teams?.["*"]);
|
|
108
|
+
const souls = {};
|
|
109
|
+
if (star.length) souls["*"] = { teams: star };
|
|
110
|
+
const named = [...Object.keys(teams ?? {}), ...Object.keys(defaults ?? {})].filter((k, i, a) => k !== "*" && a.indexOf(k) === i);
|
|
111
|
+
for (const k of named) {
|
|
112
|
+
const def = str(defaults?.[k]);
|
|
113
|
+
const list = [...star, ...labelsOf(teams?.[k])].filter((l, i, a) => a.indexOf(l) === i);
|
|
114
|
+
souls[k.includes("/") ? k : `<member>/${k}`] = { ...(def !== null ? { default: def } : {}), teams: list };
|
|
115
|
+
}
|
|
116
|
+
return { souls };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Every discovered soul's key (teamKeyOf): confirmed members' (and a standalone view's own), external and
|
|
120
|
+
* package souls. The typo guard (`team-soul-unknown`) checks the workspace's `souls:` keys against it. */
|
|
121
|
+
export function discoveredTeamKeys(discovery) {
|
|
122
|
+
const keys = new Set();
|
|
123
|
+
for (const m of discovery?.members || []) if (m.confirmed || (discovery.standalone === true && m.key === discovery.key)) for (const s of m.souls || []) keys.add(teamKeyOf(s));
|
|
124
|
+
for (const x of discovery?.external || []) if (x?.soul) keys.add(teamKeyOf({ ...x.soul, repoKey: x.soul.repoKey ?? x.key }));
|
|
125
|
+
for (const s of discovery?.packageSouls || []) keys.add(teamKeyOf(s));
|
|
126
|
+
return keys;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The `souls:` patterns that apply to `key`, most specific first. */
|
|
130
|
+
const patternsOf = (key) => (key === "*" ? ["*"] : [key, `${key.slice(0, key.lastIndexOf("/"))}/*`, "*"]);
|
|
80
131
|
|
|
81
132
|
const unknown = (label, at) => fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared (${at}): declare it with \`oats teams add\`, or in oats-workspace.yaml teams: for a shared team`, { label, at });
|
|
133
|
+
const closedMessage = (keys) => `${LOCAL_FILE} declares ${keys.join(", ")}, but oats-workspace.yaml does not allow local teams (localTeams: true): ${CLOSED_FIX}`;
|
|
134
|
+
/** Whether `e` refuses a soul's TEAMS (an undeclared label, or local teams the workspace does not allow):
|
|
135
|
+
* readiness reports it as a team item, a home falls back to its recorded teams. */
|
|
136
|
+
export const isTeamRefusal = (e) => e?.code === "E_TEAM_UNKNOWN" || (e?.code === "E_WORKSPACE_SCHEMA" && e?.details?.reason === "local-teams-closed");
|
|
137
|
+
/** Whether `e` refuses oats-local.yaml for the team keys 0.38.0 removed (`souls.teams` / `souls.default`):
|
|
138
|
+
* a reader that otherwise falls back to a home's recorded teams must answer it instead. */
|
|
139
|
+
export const isRemovedTeamKeys = (e) => e?.code === "E_WORKSPACE_SCHEMA" && Array.isArray(e?.details?.problems)
|
|
140
|
+
&& e.details.problems.some((p) => p?.reason === "removed-key" && /^\/souls\/(teams|default)$/.test(p.path));
|
|
141
|
+
/** The refusal of local teams the workspace does not allow (E_WORKSPACE_SCHEMA reason local-teams-closed). */
|
|
142
|
+
export const localTeamsClosed = (keys, { verb = null } = {}) => fail("E_WORKSPACE_SCHEMA",
|
|
143
|
+
verb ? `${verb} writes ${keys.join(", ")} in ${LOCAL_FILE}, but oats-workspace.yaml does not allow local teams (localTeams: true): ${CLOSED_FIX}` : closedMessage(keys),
|
|
144
|
+
{ reason: "local-teams-closed", path: LOCAL_FILE, keys: [...keys] });
|
|
82
145
|
|
|
83
146
|
/**
|
|
84
|
-
* One soul's teams here (key "*": the
|
|
85
|
-
* → { key, defaultTeam: DefaultTeam | null, teams: [TeamRow
|
|
86
|
-
* `
|
|
147
|
+
* One soul's teams here (key "*": what the "*" pattern gives).
|
|
148
|
+
* → { key, match, defaultMatch, defaultTeam: DefaultTeam | null, teams: [TeamRow] }; `match` / `defaultMatch`
|
|
149
|
+
* are the `souls:` keys its teams / its default come from (null: none). Throws E_WORKSPACE_SCHEMA
|
|
150
|
+
* (local-teams-closed) / E_TEAM_UNKNOWN.
|
|
87
151
|
*/
|
|
88
152
|
export function soulTeams(model, key) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const at = `${LOCAL_FILE}#/souls/default/${pointerKey(key)}`;
|
|
98
|
-
known(override, at);
|
|
99
|
-
if (override !== model.defaultTeam && !star.includes(override) && !own.includes(override)) {
|
|
100
|
-
throw fail("E_TEAM_NOT_ELIGIBLE", `souls.default.${key} is ${JSON.stringify(override)}, which is not one of ${key}'s teams here — add it first (\`oats soul teams ${key} --add ${override}\`)`, { soul: key, label: override, at });
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
const defaultLabel = override ?? model.defaultTeam;
|
|
153
|
+
if (model.closedKeys.length) throw localTeamsClosed(model.closedKeys);
|
|
154
|
+
const patterns = patternsOf(key);
|
|
155
|
+
const match = patterns.find((p) => isObject(model.souls[p])) ?? null;
|
|
156
|
+
const defaultMatch = patterns.find((p) => isObject(model.souls[p]) && typeof model.souls[p].default === "string") ?? null;
|
|
157
|
+
if (model.localDefault !== null && !model.labels.has(model.localDefault)) throw unknown(model.localDefault, `${LOCAL_FILE}#/defaultTeam`);
|
|
158
|
+
const [defaultLabel, from] = defaultMatch !== null ? [model.souls[defaultMatch].default, "soul"]
|
|
159
|
+
: model.localDefault !== null ? [model.localDefault, "deployment"]
|
|
160
|
+
: model.workspaceDefault !== null ? [model.workspaceDefault, "workspace"] : [null, null];
|
|
104
161
|
const via = new Map();
|
|
105
|
-
const add = (label, why) => { const v = via.get(label) ?? []; if (!v.includes(why)) v.push(why); via.set(label, v); };
|
|
162
|
+
const add = (label, why) => { if (!model.labels.has(label)) return; const v = via.get(label) ?? []; if (!v.includes(why)) v.push(why); via.set(label, v); };
|
|
106
163
|
if (defaultLabel !== null) add(defaultLabel, "default");
|
|
107
|
-
|
|
108
|
-
for (const l of
|
|
164
|
+
const listed = match === null ? [] : model.souls[match].teams === "any" ? [...model.shared.keys()] : labelsOf(model.souls[match].teams);
|
|
165
|
+
for (const l of listed) add(l, "workspace");
|
|
166
|
+
if (model.localTeams !== false) for (const l of model.local.keys()) add(l, "local");
|
|
109
167
|
const teams = [...via].map(([label, v]) => {
|
|
110
168
|
const d = model.labels.get(label);
|
|
111
169
|
return { label, team: d.team, default: label === defaultLabel, from: d.from, via: v };
|
|
112
170
|
}).sort((a, b) => (b.default - a.default) || byCodepoint(a.label, b.label));
|
|
113
|
-
const defaultTeam = defaultLabel === null ? null : { label: defaultLabel, team: model.labels.get(defaultLabel).team, from
|
|
114
|
-
return { key, defaultTeam, teams };
|
|
171
|
+
const defaultTeam = defaultLabel === null || !model.labels.has(defaultLabel) ? null : { label: defaultLabel, team: model.labels.get(defaultLabel).team, from };
|
|
172
|
+
return { key, match, defaultMatch, defaultTeam, teams };
|
|
115
173
|
}
|
|
116
174
|
|
|
117
|
-
/** TeamRows as the reports carry them (unmapped included
|
|
118
|
-
|
|
175
|
+
/** TeamRows as the reports carry them (unmapped included). A row a home recorded before team model 3 has
|
|
176
|
+
* no `via`, and is reported as recorded (its eligibility is not guessed). */
|
|
177
|
+
export const reportRows = (teams) => (teams || []).map(({ label, team, default: d, from, via }) => ({ label, team, default: d, from, ...(Array.isArray(via) ? { via: [...via] } : {}) }));
|
|
119
178
|
/** TeamRows as OATS_TEAMS and instance.json carry them: mapped only. */
|
|
120
179
|
export const envRows = (teams) => reportRows(teams).filter((r) => r.team !== null);
|
|
121
180
|
|
|
122
181
|
/** Every reference to `label` in oats-local.yaml, in written order (`oats teams remove` refuses on any). */
|
|
123
182
|
export function teamReferences(model, label) {
|
|
124
|
-
|
|
125
|
-
if (model.defaultTeam === label) refs.push("defaultTeam");
|
|
126
|
-
for (const [key, list] of Object.entries(model.souls.teams)) if (labelsOf(list).includes(label)) refs.push(`souls.teams:${key}`);
|
|
127
|
-
for (const [key, l] of Object.entries(model.souls.default)) if (l === label) refs.push(`souls.default:${key}`);
|
|
128
|
-
return refs;
|
|
183
|
+
return model.localDefault === label ? ["defaultTeam"] : [];
|
|
129
184
|
}
|
|
130
185
|
|
|
131
186
|
const collisionProblem = (label, shared, local) => ({
|
|
@@ -135,64 +190,48 @@ const collisionProblem = (label, shared, local) => ({
|
|
|
135
190
|
message: `team ${label} is declared in both oats-workspace.yaml (shared) and oats-local.yaml (local); the shared definition wins`,
|
|
136
191
|
fix: "rename the local label in oats-local.yaml",
|
|
137
192
|
});
|
|
138
|
-
|
|
139
|
-
|
|
193
|
+
/** Where another default is chosen: where this one comes from (`t` is soulTeams' answer). */
|
|
194
|
+
const otherDefault = (t) => t.defaultTeam?.from === "soul" ? `in the souls: entry ${t.defaultMatch} of oats-workspace.yaml`
|
|
195
|
+
: t.defaultTeam?.from === "workspace" ? "defaultTeam in oats-workspace.yaml" : "with `oats teams default`";
|
|
196
|
+
const unmappedProblem = (d, t) => d.label === t.defaultTeam?.label
|
|
197
|
+
? { code: "team-unmapped", label: d.label, default: true, severity: "failure", at: d.at, message: `the default team ${d.label} has no provider id yet`,
|
|
198
|
+
fix: `${UNMAPPED_FIX}; or choose another ${t.defaultTeam.from === "workspace" ? "" : "default "}${otherDefault(t)}` }
|
|
140
199
|
: { code: "team-unmapped", label: d.label, default: false, severity: "warning", at: d.at, message: `shared team ${d.label} has no provider id yet`, fix: UNMAPPED_FIX };
|
|
141
|
-
|
|
200
|
+
/** No default: committed in the workspace file, or recorded here where local teams are allowed (the standalone
|
|
201
|
+
* view has only the local way). */
|
|
202
|
+
const unconfiguredProblem = (model) => ({ code: "E_TEAM_UNCONFIGURED", severity: "failure", message: "no teams configured: run `oats aweb setup`",
|
|
203
|
+
fix: model.localTeams === null ? "run `oats aweb setup` (it creates the teams and sets the default), or `oats teams add <label> --team <id>`"
|
|
204
|
+
: `run \`oats aweb setup\` to create a team, then commit it in oats-workspace.yaml as defaultTeam (or as a soul's default in souls:)${model.localTeams ? ", or record it here with `oats teams add <label> --team <id>`" : ""}` });
|
|
142
205
|
const refusalProblem = (e) => ({ code: e.code, ...e.details, severity: "failure", message: e.message,
|
|
143
|
-
fix:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
export const
|
|
148
|
-
|
|
149
|
-
* The team-model-3-migration warnings (0.36.x and 0.37.x; 0.38.0 refuses what they name): `local-soul-teams` when
|
|
150
|
-
* oats-local.yaml has souls.teams / souls.default, `local-teams-closed` when it declares teams /
|
|
151
|
-
* defaultTeam and the workspace file does not say `localTeams: true` (never without a workspace file).
|
|
152
|
-
*/
|
|
153
|
-
export function migrationProblems(model) {
|
|
154
|
-
const m = model.migration, problems = [];
|
|
155
|
-
if (m.soulKeys.length) problems.push({ code: MIGRATION_CODE, severity: "warning", condition: "local-soul-teams", keys: [...m.soulKeys],
|
|
156
|
-
message: `oats-local.yaml ${m.soulKeys.join(", ")}: OATS 0.38.0 refuses ${m.soulKeys.length > 1 ? "these keys" : "this key"}; which teams a soul may join, and its default, move to souls: in oats-workspace.yaml`,
|
|
157
|
-
fix: `commit the same choices as souls: entries in oats-workspace.yaml ("*" or <member|package>/<soul>: { default, teams }), then remove ${m.soulKeys.join(" and ")} from oats-local.yaml` });
|
|
158
|
-
if (m.teamKeys.length && m.localTeams === false) problems.push(localTeamsClosedProblem(m.teamKeys));
|
|
159
|
-
return problems;
|
|
160
|
-
}
|
|
161
|
-
/** `local-teams-closed` for the oats-local.yaml `keys` found (doctor builds it from its offline read). */
|
|
162
|
-
export const localTeamsClosedProblem = (keys) => ({ code: MIGRATION_CODE, severity: "warning", condition: "local-teams-closed", keys: [...keys],
|
|
163
|
-
message: `oats-local.yaml declares ${keys.join(", ")}, but oats-workspace.yaml does not say localTeams: true: OATS 0.38.0 refuses local teams and a local defaultTeam unless the workspace allows them`,
|
|
164
|
-
fix: "either (a) add `localTeams: true` to oats-workspace.yaml, or (b) commit the teams and defaultTeam in oats-workspace.yaml, then remove them from oats-local.yaml" });
|
|
206
|
+
fix: "declare the team (`oats teams add`), or choose another default (`oats teams default`)" });
|
|
207
|
+
const soulUnknownProblem = (key, at) => ({ code: "team-soul-unknown", severity: "warning", key, at,
|
|
208
|
+
message: `souls: ${key} names no soul of this workspace (a pattern is "*" or <member|package>/*)`, fix: "correct the key to a soul's qualified name (oats souls lists them), or remove it" });
|
|
209
|
+
/** local-teams-closed as a problem (oats teams, readiness, doctor): the refusal's facts, never thrown. */
|
|
210
|
+
export const localTeamsClosedProblem = (keys) => ({ code: "E_WORKSPACE_SCHEMA", severity: "failure", condition: "local-teams-closed", path: LOCAL_FILE, keys: [...keys],
|
|
211
|
+
message: closedMessage(keys), fix: CLOSED_FIX });
|
|
165
212
|
|
|
166
213
|
/**
|
|
167
|
-
* Readiness problems. Without `key`: the deployment's (`oats teams`): collisions, unmapped shared
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* `messaging`: a messaging layer is active, so no default is
|
|
214
|
+
* Readiness problems. Without `key`: the deployment's (`oats teams`): collisions, unmapped shared teams
|
|
215
|
+
* (a failure when it is the default a soul without a souls: default gets), an unknown local default.
|
|
216
|
+
* With `key`: only what concerns that soul (`default` marks ITS default). Local teams the workspace does
|
|
217
|
+
* not allow are the one problem either way. `messaging`: a messaging layer is active, so no default is
|
|
218
|
+
* E_TEAM_UNCONFIGURED. `soulKeys` (discoveredTeamKeys; null when the souls are not known): a `souls:` key
|
|
219
|
+
* that is neither a pattern nor one of them is `team-soul-unknown` (a typo guard, either way).
|
|
171
220
|
*/
|
|
172
|
-
export function teamProblems(model, { key = null, messaging = false } = {}) {
|
|
221
|
+
export function teamProblems(model, { key = null, messaging = false, soulKeys = null } = {}) {
|
|
222
|
+
if (model.closedKeys.length) return [localTeamsClosedProblem(model.closedKeys)];
|
|
173
223
|
const problems = [];
|
|
174
|
-
if
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
for (const r of t.teams) if (model.shared.has(r.label) && model.local.has(r.label)) problems.push(collisionProblem(r.label, model.shared.get(r.label), model.local.get(r.label)));
|
|
179
|
-
for (const r of t.teams) if (r.team === null) problems.push(unmappedProblem(model.labels.get(r.label), r.default));
|
|
180
|
-
if (messaging && t.defaultTeam === null) problems.push(unconfiguredProblem());
|
|
181
|
-
return [...problems, ...migrationProblems(model)];
|
|
182
|
-
}
|
|
183
|
-
const labels = [...model.labels.keys()].sort(byCodepoint);
|
|
224
|
+
// An undeclared local default is reported; the rest is judged as if it were not set.
|
|
225
|
+
if (model.localDefault !== null && !model.labels.has(model.localDefault)) problems.push(refusalProblem(unknown(model.localDefault, `${LOCAL_FILE}#/defaultTeam`)));
|
|
226
|
+
const t = soulTeams(problems.length ? { ...model, localDefault: null } : model, key ?? "*");
|
|
227
|
+
const labels = key !== null ? t.teams.map((r) => r.label) : [...model.labels.keys()].sort(byCodepoint);
|
|
184
228
|
for (const label of labels) if (model.shared.has(label) && model.local.has(label)) problems.push(collisionProblem(label, model.shared.get(label), model.local.get(label)));
|
|
185
|
-
for (const label of labels) { const d = model.labels.get(label); if (d.team === null) problems.push(unmappedProblem(d,
|
|
186
|
-
|
|
187
|
-
if (
|
|
188
|
-
|
|
189
|
-
for (const [k, l] of Object.entries(model.souls.default)) if (typeof l === "string") known(l, `${LOCAL_FILE}#/souls/default/${pointerKey(k)}`);
|
|
190
|
-
for (const [k, l] of Object.entries(model.souls.default)) {
|
|
191
|
-
if (typeof l !== "string" || !model.labels.has(l)) continue;
|
|
192
|
-
try { soulTeams(model, k); } catch (e) { if (e.code === "E_TEAM_NOT_ELIGIBLE" && e.details.soul === k) problems.push(refusalProblem(e)); else if (e.code !== "E_TEAM_UNKNOWN") throw e; }
|
|
229
|
+
for (const label of labels) { const d = model.labels.get(label); if (d.team === null) problems.push(unmappedProblem(d, t)); }
|
|
230
|
+
if (messaging && t.defaultTeam === null && model.localDefault === null) problems.push(unconfiguredProblem(model));
|
|
231
|
+
if (soulKeys !== null) for (const k of Object.keys(model.souls).sort(byCodepoint)) {
|
|
232
|
+
if (k !== "*" && !k.endsWith("/*") && !soulKeys.has(k)) problems.push(soulUnknownProblem(k, `${model.soulsAt}/${pointerKey(k)}`));
|
|
193
233
|
}
|
|
194
|
-
|
|
195
|
-
return [...problems, ...migrationProblems(model)];
|
|
234
|
+
return problems;
|
|
196
235
|
}
|
|
197
236
|
|
|
198
237
|
/**
|