@synap-core/workspace-templates 0.11.0 → 0.12.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/dist/index.d.ts +15 -0
- package/dist/index.js +802 -4
- package/dist/index.js.map +1 -1
- package/package.json +16 -25
- package/src/base.template.test.ts +25 -19
- package/src/base.yaml +10 -0
- package/src/builder.yaml +19 -1
- package/src/business-model.yaml +351 -0
- package/src/catalog.ts +19 -0
- package/src/civic-france.yaml +102 -0
- package/src/dist-freshness.test.ts +97 -0
- package/src/foundation.yaml +62 -0
- package/src/grants.yaml +17 -6
- package/src/life-os.yaml +14 -0
- package/src/seed-obligation.test.ts +181 -0
- package/src/shared-role-ssot.test.ts +4 -1
- package/src/templates.ts +765 -3
- package/src/validate-flow.ts +11 -1
- package/src/validate.test.ts +86 -0
- package/src/validate.ts +49 -0
- package/src/__fixtures__/report-automation-flow.json +0 -393
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# France civic open-data research overlay — STUB (enhance later)
|
|
2
|
+
#
|
|
3
|
+
# Thin overlay that composes onto research-base for investigating French public
|
|
4
|
+
# data (open data / Licence Ouverte). NOT a government product, NOT a marketplace
|
|
5
|
+
# civic-tech app. Private workspace template only.
|
|
6
|
+
#
|
|
7
|
+
# Data access: consume official APIs only — data.gouv.fr, api.gouv.fr, PISTE.
|
|
8
|
+
# Never scrape HTML. Do not invent a public pod host or resource_shares.
|
|
9
|
+
# No automations that fetch the web in this stub.
|
|
10
|
+
|
|
11
|
+
meta:
|
|
12
|
+
slug: civic-france
|
|
13
|
+
domain: knowledge
|
|
14
|
+
name: France Civic Research
|
|
15
|
+
description: Overlay for investigating French public data (open data / Licence Ouverte). Composes onto Research. Not a government product — a private research stub.
|
|
16
|
+
icon: landmark
|
|
17
|
+
color: "#1D4ED8"
|
|
18
|
+
tags: [research, civic, france, open-data, licence-ouverte]
|
|
19
|
+
isPublic: false
|
|
20
|
+
|
|
21
|
+
workspace:
|
|
22
|
+
name: Research
|
|
23
|
+
description: Adds a French civic open-data lens onto the Research substrate — track public datasets consulted during investigations.
|
|
24
|
+
subtype: research-base
|
|
25
|
+
|
|
26
|
+
# Layer additively onto the research-base workspace (no second workspace).
|
|
27
|
+
# Visibility and capabilities OMITTED so the base Research workspace keeps its own.
|
|
28
|
+
dependencies:
|
|
29
|
+
- slug: research-base
|
|
30
|
+
relation: compose
|
|
31
|
+
reason: France civic research is a research method — it needs the Research substrate (question / research / source) to work over.
|
|
32
|
+
|
|
33
|
+
profiles:
|
|
34
|
+
# Slugs this overlay reuses from research-base — declared for the per-file
|
|
35
|
+
# validator (base owns their full schema; the applier skips already-existing
|
|
36
|
+
# properties). Mirror research-decision's reuse pattern.
|
|
37
|
+
- slug: question
|
|
38
|
+
displayName: Question
|
|
39
|
+
icon: help-circle
|
|
40
|
+
color: blue
|
|
41
|
+
description: An open question driving a civic-data investigation.
|
|
42
|
+
properties:
|
|
43
|
+
- slug: questionStatus
|
|
44
|
+
label: Status
|
|
45
|
+
valueType: string
|
|
46
|
+
inputType: select
|
|
47
|
+
enumValues: [open, exploring, answered, abandoned]
|
|
48
|
+
constraints: { defaultValue: open }
|
|
49
|
+
|
|
50
|
+
- slug: research
|
|
51
|
+
displayName: Research
|
|
52
|
+
icon: microscope
|
|
53
|
+
color: violet
|
|
54
|
+
description: An investigation into French public data — sources, findings, conclusion.
|
|
55
|
+
properties:
|
|
56
|
+
- slug: researchStatus
|
|
57
|
+
label: Status
|
|
58
|
+
valueType: string
|
|
59
|
+
inputType: select
|
|
60
|
+
enumValues: [ongoing, concluded, abandoned]
|
|
61
|
+
constraints: { defaultValue: ongoing }
|
|
62
|
+
|
|
63
|
+
- slug: source
|
|
64
|
+
displayName: Source
|
|
65
|
+
icon: file-text
|
|
66
|
+
color: cyan
|
|
67
|
+
description: Anything consulted — API payload, dataset page, decree, report.
|
|
68
|
+
properties:
|
|
69
|
+
- slug: url
|
|
70
|
+
label: URL
|
|
71
|
+
valueType: string
|
|
72
|
+
inputType: url
|
|
73
|
+
|
|
74
|
+
# Workspace-owned catalog entry for a French public dataset (thin stub).
|
|
75
|
+
- slug: public_dataset
|
|
76
|
+
displayName: Public dataset
|
|
77
|
+
icon: database
|
|
78
|
+
color: blue
|
|
79
|
+
description: A French public dataset consulted via official open-data APIs (data.gouv.fr / api.gouv.fr / PISTE) — never scraped HTML.
|
|
80
|
+
scope: WORKSPACE
|
|
81
|
+
properties:
|
|
82
|
+
- slug: sourceUrl
|
|
83
|
+
label: Source URL
|
|
84
|
+
valueType: string
|
|
85
|
+
inputType: url
|
|
86
|
+
- slug: license
|
|
87
|
+
label: License
|
|
88
|
+
valueType: string
|
|
89
|
+
inputType: text
|
|
90
|
+
- slug: publisher
|
|
91
|
+
label: Publisher
|
|
92
|
+
valueType: string
|
|
93
|
+
inputType: text
|
|
94
|
+
|
|
95
|
+
views:
|
|
96
|
+
- name: Public datasets
|
|
97
|
+
slug: civic-france-public-datasets
|
|
98
|
+
type: table
|
|
99
|
+
scopeProfileSlug: public_dataset
|
|
100
|
+
description: French public datasets tracked in this research workspace.
|
|
101
|
+
config:
|
|
102
|
+
columns: [title, publisher, license, sourceUrl]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FRESHNESS GATE for the BUILT `dist/` artifact.
|
|
3
|
+
*
|
|
4
|
+
* WHY (read before deleting):
|
|
5
|
+
* `templates-freshness.test.ts` (sibling file) proves `src/templates.ts`
|
|
6
|
+
* matches `src/*.yaml`. That is necessary but not sufficient: `dist/` — built
|
|
7
|
+
* by `tsup` from `src/templates.ts` — is what `synap-backend` and every other
|
|
8
|
+
* consumer actually imports as the published `@synap-core/workspace-templates`
|
|
9
|
+
* npm dependency (see this package's `exports` field: `dist/index.js`).
|
|
10
|
+
* Nothing regenerates `dist/` automatically, and it is committed independently
|
|
11
|
+
* of `src/templates.ts`. A YAML edit can reach `src/templates.ts` (green on
|
|
12
|
+
* the sibling test) and NOT reach `dist/` — the sibling test stays green while
|
|
13
|
+
* the backend keeps reading stale data, because it never looks at `dist/` at
|
|
14
|
+
* all. That gap is exactly what happened on 2026-09-05.
|
|
15
|
+
*
|
|
16
|
+
* This test closes it by importing the BUILT `dist/index.js` module (not
|
|
17
|
+
* re-parsing its text — a bundled/minified file has no byte-for-byte
|
|
18
|
+
* relationship to `src/templates.ts`) and asserting its exported
|
|
19
|
+
* `WORKSPACE_TEMPLATES` deep-equals what `src/*.yaml` renders to right now.
|
|
20
|
+
* That is the same non-vacuous shape as the sibling test: it re-derives the
|
|
21
|
+
* expected value from the YAML source on every run rather than trusting any
|
|
22
|
+
* stored marker, and it asserts the corpus is non-empty so a broken import
|
|
23
|
+
* can't silently compare `{}` to `{}`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, expect, it } from "vitest";
|
|
27
|
+
import { readYamlTemplates } from "./generate.mjs";
|
|
28
|
+
|
|
29
|
+
const FIX_COMMAND =
|
|
30
|
+
"cd synap-app/packages/workspace-templates && pnpm build";
|
|
31
|
+
|
|
32
|
+
describe("dist/ freshness", () => {
|
|
33
|
+
it("dist/index.js's WORKSPACE_TEMPLATES matches what src/*.yaml renders to", async () => {
|
|
34
|
+
const expectedEntries = readYamlTemplates().map((t) => [t.slug, t.data]);
|
|
35
|
+
const expected = Object.fromEntries(expectedEntries);
|
|
36
|
+
|
|
37
|
+
// Guard against a vacuous pass: if the YAML corpus itself came back empty
|
|
38
|
+
// (a broken readdir, an emptied src/ directory), an empty-vs-empty
|
|
39
|
+
// comparison below would prove nothing. Fail loudly instead.
|
|
40
|
+
if (expectedEntries.length === 0) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"readYamlTemplates() returned zero templates — src/*.yaml did not " +
|
|
43
|
+
"parse. This test cannot verify dist/ freshness against an empty " +
|
|
44
|
+
"expected corpus; fix the YAML read before trusting this gate.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let dist: { WORKSPACE_TEMPLATES?: Record<string, unknown> };
|
|
49
|
+
try {
|
|
50
|
+
// dist/index.js is the BUILT artifact — import it live so we're
|
|
51
|
+
// comparing against exactly what a consumer (synap-backend, the CLI)
|
|
52
|
+
// resolves via this package's `exports` field, not a re-parse of its
|
|
53
|
+
// source text.
|
|
54
|
+
dist = await import("../dist/index.js");
|
|
55
|
+
} catch (err) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
[
|
|
58
|
+
"",
|
|
59
|
+
"❌ dist/index.js could not be imported — it is missing or fails to load.",
|
|
60
|
+
"",
|
|
61
|
+
` FIX — build it:`,
|
|
62
|
+
` ${FIX_COMMAND}`,
|
|
63
|
+
"",
|
|
64
|
+
` (original error: ${(err as Error).message})`,
|
|
65
|
+
"",
|
|
66
|
+
].join("\n"),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const actual = dist.WORKSPACE_TEMPLATES;
|
|
71
|
+
if (!actual || Object.keys(actual).length === 0) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"dist/index.js loaded but exported no WORKSPACE_TEMPLATES (or an " +
|
|
74
|
+
`empty object) — it is stale or broken.\n\n FIX:\n ${FIX_COMMAND}\n`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
[
|
|
81
|
+
"",
|
|
82
|
+
"❌ dist/index.js is STALE — its WORKSPACE_TEMPLATES no longer matches",
|
|
83
|
+
" what src/*.yaml renders to. synap-backend and every other consumer",
|
|
84
|
+
" resolve this package to dist/, not src/, so a stale dist/ means the",
|
|
85
|
+
" backend is reading templates that no longer exist on disk — even",
|
|
86
|
+
" though src/templates.ts (and templates-freshness.test.ts) are fresh.",
|
|
87
|
+
"",
|
|
88
|
+
" FIX — rebuild and commit the result:",
|
|
89
|
+
` ${FIX_COMMAND}`,
|
|
90
|
+
"",
|
|
91
|
+
].join("\n"),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
expect(actual).toEqual(expected);
|
|
96
|
+
});
|
|
97
|
+
});
|
package/src/foundation.yaml
CHANGED
|
@@ -528,6 +528,68 @@ profiles:
|
|
|
528
528
|
valueType: string
|
|
529
529
|
inputType: textarea
|
|
530
530
|
|
|
531
|
+
# ── Roles the BUSINESS MODEL needs, and why they live here ─────────────────
|
|
532
|
+
# `porteur` and `funder` are hats, not kinds: the same person entity is a
|
|
533
|
+
# porteur of one venture and a funder of another, and the hat is revocable
|
|
534
|
+
# while the person is not. They are declared HERE because foundation.yaml is
|
|
535
|
+
# the shared-role SSOT (`shared-role-ssot.test.ts` derives the guarded
|
|
536
|
+
# vocabulary from this file) — declaring them in business-model.yaml instead
|
|
537
|
+
# would fork a second identity for a hat the whole pod refers to.
|
|
538
|
+
- slug: porteur
|
|
539
|
+
displayName: Porteur
|
|
540
|
+
icon: user-check
|
|
541
|
+
color: amber
|
|
542
|
+
description: The person carrying a venture — the one whose commitment, skills, and constraints the model actually rests on. Distinct from `client` (who pays) and `partner` (who collaborates).
|
|
543
|
+
scope: shared
|
|
544
|
+
profileKind: role
|
|
545
|
+
applicableKinds:
|
|
546
|
+
- person
|
|
547
|
+
properties:
|
|
548
|
+
- slug: brings
|
|
549
|
+
label: What They Bring
|
|
550
|
+
valueType: string
|
|
551
|
+
inputType: textarea
|
|
552
|
+
description: The specific competence, network, capital, or legitimacy this person contributes that the model depends on.
|
|
553
|
+
- slug: commitmentLevel
|
|
554
|
+
label: Commitment
|
|
555
|
+
valueType: string
|
|
556
|
+
inputType: select
|
|
557
|
+
enumValues: [full-time, part-time, advisory, intermittent]
|
|
558
|
+
- slug: constraints
|
|
559
|
+
label: Constraints
|
|
560
|
+
valueType: string
|
|
561
|
+
inputType: textarea
|
|
562
|
+
description: What limits this porteur — time, cash runway, geography, non-compete, family. A model that ignores these is fiction.
|
|
563
|
+
- slug: funder
|
|
564
|
+
displayName: Funder
|
|
565
|
+
icon: banknote
|
|
566
|
+
color: green
|
|
567
|
+
description: A source of non-revenue money or subsidy — a bank, a public scheme, an investor, a grantor. A hat worn by a company or a person, never a kind of its own.
|
|
568
|
+
scope: shared
|
|
569
|
+
profileKind: role
|
|
570
|
+
applicableKinds:
|
|
571
|
+
- company
|
|
572
|
+
- person
|
|
573
|
+
properties:
|
|
574
|
+
- slug: fundingType
|
|
575
|
+
label: Funding Type
|
|
576
|
+
valueType: string
|
|
577
|
+
inputType: select
|
|
578
|
+
enumValues: [grant, subsidy, loan, equity, guarantee, prize, other]
|
|
579
|
+
- slug: amountCommitted
|
|
580
|
+
label: Amount Committed
|
|
581
|
+
valueType: number
|
|
582
|
+
inputType: number
|
|
583
|
+
- slug: eligibilityConditions
|
|
584
|
+
label: Eligibility Conditions
|
|
585
|
+
valueType: string
|
|
586
|
+
inputType: textarea
|
|
587
|
+
description: The conditions the venture must satisfy to keep this money. These are the constraints most often discovered too late.
|
|
588
|
+
- slug: decisionDate
|
|
589
|
+
label: Decision Date
|
|
590
|
+
valueType: date
|
|
591
|
+
inputType: date
|
|
592
|
+
|
|
531
593
|
views:
|
|
532
594
|
- name: Mission & Vision
|
|
533
595
|
slug: mission-vision
|
package/src/grants.yaml
CHANGED
|
@@ -1020,15 +1020,26 @@ playbooks:
|
|
|
1020
1020
|
params:
|
|
1021
1021
|
- { name: contract, type: entity, profileSlug: contract, label: Engagement, required: true }
|
|
1022
1022
|
- { name: grantProgram, type: entity, profileSlug: grant-program, label: Grant Program, required: true }
|
|
1023
|
+
# A grant pursuit is an ENGAGEMENT with a before/during/after lifecycle that
|
|
1024
|
+
# outlives any one working session (drafting, then weeks of waiting on the
|
|
1025
|
+
# funder, then tranche delivery), so this is a PROJECT template: its stages
|
|
1026
|
+
# coordinate a long-running container rather than script one focus session.
|
|
1027
|
+
scope: project
|
|
1023
1028
|
# Generic stages, mapped onto grant_submission's own stage/status/outcome +
|
|
1024
1029
|
# tranche vocab so the run can maintain the card as it advances.
|
|
1030
|
+
# `category` is the closed rollup bucket (backlog|planned|started|paused|
|
|
1031
|
+
# completed|canceled), chosen by MEANING: `identify` is pre-commitment
|
|
1032
|
+
# scoping (planned); `awaiting` is genuinely blocked on the funder (paused);
|
|
1033
|
+
# `awarded` stays `started` because delivery follows an award — a rejected
|
|
1034
|
+
# pursuit's terminality lives on the submission's own `outcome` field, not
|
|
1035
|
+
# here.
|
|
1025
1036
|
stages:
|
|
1026
|
-
- { key: identify, name: Identify, description: "Understand the project and which of the program's submission tracks fit — before any writing.", goal: "A clear project understanding and the fitting submission track; submission stage = scope." }
|
|
1027
|
-
- { key: draft, name: Draft, description: "Write the abstract, then the full proposal, from the client's real material.", goal: "Abstract then full proposal drafted; submission stage abstract → build with body fields filled." }
|
|
1028
|
-
- { key: submit, name: Submit, description: "Finalize against the program deadline and submit the application.", goal: "Application submitted on time; submission stage = awaiting-results." }
|
|
1029
|
-
- { key: awaiting, name: Awaiting, description: "Track the decision and keep the client status current.", goal: "Submission status reflects where the decision stands (in-review / waiting-client / needs-document / blocked)." }
|
|
1030
|
-
- { key: awarded, name: Awarded, description: "Record the funder's decision and the awarded amount.", goal: "Submission outcome set (awarded / rejected / partial) with the awarded amount." }
|
|
1031
|
-
- { key: delivering, name: Delivering, description: "Work the program's tranche model — ship and record each tranche.", goal: "Tranche deliverables filled and trancheCompletion updated as milestones ship." }
|
|
1037
|
+
- { key: identify, name: Identify, category: planned, description: "Understand the project and which of the program's submission tracks fit — before any writing.", goal: "A clear project understanding and the fitting submission track; submission stage = scope." }
|
|
1038
|
+
- { key: draft, name: Draft, category: started, description: "Write the abstract, then the full proposal, from the client's real material.", goal: "Abstract then full proposal drafted; submission stage abstract → build with body fields filled." }
|
|
1039
|
+
- { key: submit, name: Submit, category: started, description: "Finalize against the program deadline and submit the application.", goal: "Application submitted on time; submission stage = awaiting-results." }
|
|
1040
|
+
- { key: awaiting, name: Awaiting, category: paused, description: "Track the decision and keep the client status current.", goal: "Submission status reflects where the decision stands (in-review / waiting-client / needs-document / blocked)." }
|
|
1041
|
+
- { key: awarded, name: Awarded, category: started, description: "Record the funder's decision and the awarded amount.", goal: "Submission outcome set (awarded / rejected / partial) with the awarded amount." }
|
|
1042
|
+
- { key: delivering, name: Delivering, category: started, description: "Work the program's tranche model — ship and record each tranche.", goal: "Tranche deliverables filled and trancheCompletion updated as milestones ship." }
|
|
1032
1043
|
executor: is-agent
|
|
1033
1044
|
grants:
|
|
1034
1045
|
- entity.create
|
package/src/life-os.yaml
CHANGED
|
@@ -731,6 +731,16 @@ profiles:
|
|
|
731
731
|
inputType: text
|
|
732
732
|
|
|
733
733
|
views:
|
|
734
|
+
# Knowledge is the pod-owned, validated-memory kind. Notes remain the
|
|
735
|
+
# workspace's capture inbox; this view deliberately reuses the system
|
|
736
|
+
# contract instead of recreating a competing knowledge schema here.
|
|
737
|
+
- name: Knowledge
|
|
738
|
+
slug: knowledge
|
|
739
|
+
type: table
|
|
740
|
+
scopeProfileSlug: knowledge
|
|
741
|
+
description: Validated insights and cautions, with their long-form context and evidence kept in the graph.
|
|
742
|
+
config:
|
|
743
|
+
columns: [title, knowledgeForm]
|
|
734
744
|
- name: All Notes
|
|
735
745
|
type: table
|
|
736
746
|
scopeProfileSlug: note
|
|
@@ -1160,6 +1170,10 @@ layout:
|
|
|
1160
1170
|
appId: dashboard
|
|
1161
1171
|
label: Brain Home
|
|
1162
1172
|
icon: Home
|
|
1173
|
+
- kind: profile
|
|
1174
|
+
profileSlug: knowledge
|
|
1175
|
+
label: Knowledge
|
|
1176
|
+
icon: Brain
|
|
1163
1177
|
- kind: profile
|
|
1164
1178
|
profileSlug: note
|
|
1165
1179
|
label: Notes
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed-obligation guard.
|
|
3
|
+
*
|
|
4
|
+
* THE BUG THIS EXISTS TO STOP, stated plainly: the applier has supported
|
|
5
|
+
* `seedEntities` and `suggestedRelations` since
|
|
6
|
+
* `create-workspace-from-definition.ts` learned to read them, and for 31 of 31
|
|
7
|
+
* shipped templates that support was exercised exactly zero times. The
|
|
8
|
+
* observable result is a workspace that installs 15 views over 0 entities —
|
|
9
|
+
* every board empty, every chart blank, and the user left to guess what the
|
|
10
|
+
* pack was for. Two real workspaces in the field reproduced this identically,
|
|
11
|
+
* which is the signature of a template defect rather than user behaviour.
|
|
12
|
+
*
|
|
13
|
+
* The rule: a template that ships VIEWS over profiles it OWNS must also ship
|
|
14
|
+
* at least one seed entity, so the workspace opens with something in it.
|
|
15
|
+
*
|
|
16
|
+
* The escape hatch is deliberate and narrow. `SEEDLESS_BY_DESIGN` lists the
|
|
17
|
+
* templates that predate this guard, each with the reason it is still empty.
|
|
18
|
+
* Adding a line there is legal; adding one silently is not, because the list is
|
|
19
|
+
* asserted to be exact — a template that gains seeds must leave the list in the
|
|
20
|
+
* same commit. That is what turns this from a lint into a shrinking backlog.
|
|
21
|
+
*
|
|
22
|
+
* What a seed is NOT: filler. Seeding three fake companies teaches nothing and
|
|
23
|
+
* gets deleted in the first minute. Seed the QUESTIONS, the stages, the
|
|
24
|
+
* checklist — the reusable part of the method. See `business-model.yaml`, which
|
|
25
|
+
* seeds the nine interrogations rather than an example business.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, expect, it } from "vitest";
|
|
29
|
+
import { WORKSPACE_TEMPLATES } from "./templates.js";
|
|
30
|
+
import type { WorkspaceYaml } from "./types.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Templates that ship views over their own profiles and NO seed entities.
|
|
34
|
+
*
|
|
35
|
+
* Every entry is a workspace that currently installs empty. Keep the reason
|
|
36
|
+
* honest — "not done yet" is a valid reason and a useful one, because it reads
|
|
37
|
+
* as debt instead of as a decision.
|
|
38
|
+
*/
|
|
39
|
+
const SEEDLESS_BY_DESIGN: Record<string, string> = {
|
|
40
|
+
"agent-fleet": "Pre-dates this guard. Fleet contents are per-pod; needs a seed pass.",
|
|
41
|
+
base: "Pod substrate, not a workspace a user opens. Seeds would land in every workspace.",
|
|
42
|
+
"blockchain-ecosystem": "Pre-dates this guard; needs a seed pass.",
|
|
43
|
+
"brand-library": "Pre-dates this guard; brand assets are inherently per-company.",
|
|
44
|
+
"builder-workspace": "Pre-dates this guard; needs a seed pass.",
|
|
45
|
+
"business-developer": "Pre-dates this guard; overlay on CRM, seeds belong on the base.",
|
|
46
|
+
"civic-france": "Pre-dates this guard; needs a seed pass.",
|
|
47
|
+
communication: "Pre-dates this guard; needs a seed pass.",
|
|
48
|
+
"content-os": "Pre-dates this guard; needs a seed pass.",
|
|
49
|
+
"content-studio": "Pre-dates this guard; needs a seed pass.",
|
|
50
|
+
crm: "Pre-dates this guard; needs a seed pass.",
|
|
51
|
+
"dev-dashboard": "Pre-dates this guard; needs a seed pass.",
|
|
52
|
+
ecosystem: "Pre-dates this guard; needs a seed pass.",
|
|
53
|
+
"enterprise-os": "Pre-dates this guard; needs a seed pass.",
|
|
54
|
+
finance:
|
|
55
|
+
"Pre-dates this guard. Confirmed empty in the field: 15 views, 6 profiles, 0 entities. First candidate for a seed pass — a chart of accounts and a metric set are genuinely reusable.",
|
|
56
|
+
foundation:
|
|
57
|
+
"Pre-dates this guard. Strategic DNA is company-specific, but the INTERROGATION is not — see business-model.yaml, which seeds it as an overlay.",
|
|
58
|
+
grants: "Pre-dates this guard; needs a seed pass.",
|
|
59
|
+
hr: "Pre-dates this guard; needs a seed pass.",
|
|
60
|
+
"internal-runbook": "Pre-dates this guard; needs a seed pass.",
|
|
61
|
+
legal: "Pre-dates this guard; needs a seed pass.",
|
|
62
|
+
"life-os": "Pre-dates this guard; needs a seed pass.",
|
|
63
|
+
"marketing-campaign": "Pre-dates this guard; needs a seed pass.",
|
|
64
|
+
"meetings-knowledge": "Pre-dates this guard; needs a seed pass.",
|
|
65
|
+
networking: "Pre-dates this guard; needs a seed pass.",
|
|
66
|
+
operations: "Pre-dates this guard; needs a seed pass.",
|
|
67
|
+
"outreach-comms": "Pre-dates this guard; needs a seed pass.",
|
|
68
|
+
"project-management": "Pre-dates this guard; needs a seed pass.",
|
|
69
|
+
"research-base":
|
|
70
|
+
"Pre-dates this guard. Radar reproduced the empty-workspace pathology on top of this substrate; seed pass wanted.",
|
|
71
|
+
"research-decision": "Overlay on research-base; the method is a playbook, not entities.",
|
|
72
|
+
social: "Pre-dates this guard; needs a seed pass.",
|
|
73
|
+
"the-arch": "Pre-dates this guard; needs a seed pass.",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Slugs of profiles this template declares itself (owned or re-declared). */
|
|
77
|
+
function declaredProfileSlugs(tpl: WorkspaceYaml): Set<string> {
|
|
78
|
+
const profiles = (tpl as { profiles?: Array<{ slug?: string }> }).profiles ?? [];
|
|
79
|
+
return new Set(profiles.map((p) => p.slug).filter((s): s is string => Boolean(s)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Views whose scope is a profile the template declares. */
|
|
83
|
+
function viewsOverOwnProfiles(tpl: WorkspaceYaml): number {
|
|
84
|
+
const declared = declaredProfileSlugs(tpl);
|
|
85
|
+
const views =
|
|
86
|
+
(tpl as { views?: Array<{ scopeProfileSlug?: string; scopeProfileSlugs?: string[] }> })
|
|
87
|
+
.views ?? [];
|
|
88
|
+
return views.filter((v) => {
|
|
89
|
+
const scoped = [v.scopeProfileSlug, ...(v.scopeProfileSlugs ?? [])].filter(Boolean);
|
|
90
|
+
return scoped.some((s) => declared.has(s as string));
|
|
91
|
+
}).length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function seedCount(tpl: WorkspaceYaml): number {
|
|
95
|
+
const t = tpl as {
|
|
96
|
+
seedEntities?: unknown[];
|
|
97
|
+
suggestedEntities?: unknown[];
|
|
98
|
+
};
|
|
99
|
+
return (t.seedEntities?.length ?? 0) + (t.suggestedEntities?.length ?? 0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const slugOf = (slug: string, tpl: WorkspaceYaml) =>
|
|
103
|
+
(tpl as { meta?: { slug?: string } }).meta?.slug ?? slug;
|
|
104
|
+
|
|
105
|
+
describe("workspace-templates — seed obligation (corpus)", () => {
|
|
106
|
+
it("every template with views over its own profiles ships seeds, or is declared seedless", () => {
|
|
107
|
+
const undeclared: string[] = [];
|
|
108
|
+
|
|
109
|
+
for (const [key, tpl] of Object.entries(WORKSPACE_TEMPLATES)) {
|
|
110
|
+
const slug = slugOf(key, tpl);
|
|
111
|
+
if (viewsOverOwnProfiles(tpl) === 0) continue;
|
|
112
|
+
if (seedCount(tpl) > 0) continue;
|
|
113
|
+
if (slug in SEEDLESS_BY_DESIGN) continue;
|
|
114
|
+
undeclared.push(
|
|
115
|
+
`${slug}: ships ${viewsOverOwnProfiles(tpl)} view(s) over its own profiles and 0 seed entities. ` +
|
|
116
|
+
`Add seedEntities (seed the METHOD, not fake data), or add "${slug}" to SEEDLESS_BY_DESIGN with a reason.`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
expect(
|
|
121
|
+
undeclared,
|
|
122
|
+
`Templates would install a workspace of empty views:\n - ${undeclared.join("\n - ")}`
|
|
123
|
+
).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("the seedless allowlist is exact — a template that gains seeds must leave it", () => {
|
|
127
|
+
const stale: string[] = [];
|
|
128
|
+
|
|
129
|
+
for (const [key, tpl] of Object.entries(WORKSPACE_TEMPLATES)) {
|
|
130
|
+
const slug = slugOf(key, tpl);
|
|
131
|
+
if (!(slug in SEEDLESS_BY_DESIGN)) continue;
|
|
132
|
+
if (seedCount(tpl) > 0) {
|
|
133
|
+
stale.push(`${slug}: now seeds ${seedCount(tpl)} entity(ies) — remove it from SEEDLESS_BY_DESIGN`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const corpusSlugs = new Set(
|
|
138
|
+
Object.entries(WORKSPACE_TEMPLATES).map(([key, tpl]) => slugOf(key, tpl))
|
|
139
|
+
);
|
|
140
|
+
for (const slug of Object.keys(SEEDLESS_BY_DESIGN)) {
|
|
141
|
+
if (!corpusSlugs.has(slug)) {
|
|
142
|
+
stale.push(`${slug}: listed in SEEDLESS_BY_DESIGN but no such template exists`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
expect(stale, `Stale seedless allowlist:\n - ${stale.join("\n - ")}`).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("every seeded relation names entities seeded in the same template", () => {
|
|
150
|
+
// A suggested relation whose ref matches no seeded title fails SILENTLY at
|
|
151
|
+
// apply time — the workspace installs, the edge does not, and nothing says
|
|
152
|
+
// so. Catch the typo here instead of in a user's pod.
|
|
153
|
+
const dangling: string[] = [];
|
|
154
|
+
|
|
155
|
+
for (const [key, tpl] of Object.entries(WORKSPACE_TEMPLATES)) {
|
|
156
|
+
const slug = slugOf(key, tpl);
|
|
157
|
+
const t = tpl as {
|
|
158
|
+
seedEntities?: Array<{ title?: string }>;
|
|
159
|
+
suggestedEntities?: Array<{ title?: string }>;
|
|
160
|
+
suggestedRelations?: Array<{ sourceRef?: string; targetRef?: string; type?: string }>;
|
|
161
|
+
};
|
|
162
|
+
const rels = t.suggestedRelations ?? [];
|
|
163
|
+
if (rels.length === 0) continue;
|
|
164
|
+
|
|
165
|
+
const titles = new Set(
|
|
166
|
+
[...(t.seedEntities ?? []), ...(t.suggestedEntities ?? [])]
|
|
167
|
+
.map((e) => e.title)
|
|
168
|
+
.filter((x): x is string => Boolean(x))
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
for (const rel of rels) {
|
|
172
|
+
for (const ref of [rel.sourceRef, rel.targetRef]) {
|
|
173
|
+
if (!ref || titles.has(ref)) continue;
|
|
174
|
+
dangling.push(`${slug}: relation "${rel.type}" references unseeded entity "${ref}"`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
expect(dangling, `Dangling seeded relations:\n - ${dangling.join("\n - ")}`).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -122,8 +122,11 @@ describe("workspace-templates — shared ecosystem role SSOT (corpus)", () => {
|
|
|
122
122
|
// Guards the derivation itself: if foundation ever stops declaring its
|
|
123
123
|
// shared roles, rule (a) would silently guard nothing.
|
|
124
124
|
expect(foundation, "foundation template must exist in the corpus").toBeDefined();
|
|
125
|
+
// `porteur` and `funder` join the relationship roles: a business model
|
|
126
|
+
// rests on WHO carries it and WHO funds it, and both are revocable hats on
|
|
127
|
+
// an existing person/company rather than kinds of their own.
|
|
125
128
|
expect([...SHARED_ROLE_SLUGS].sort()).toEqual(
|
|
126
|
-
["churned", "client", "lead", "partner", "sponsor"]
|
|
129
|
+
["churned", "client", "funder", "lead", "partner", "porteur", "sponsor"]
|
|
127
130
|
);
|
|
128
131
|
});
|
|
129
132
|
|