@hippo-digital/hippocampus 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/assets/dist/hippocampus.css +1 -0
- package/assets/hippocampus.scss +896 -0
- package/assets/javascript/blueprint.js +123 -0
- package/bin/hippocampus.js +106 -0
- package/docs/agent-roadmap.md +217 -0
- package/docs/agent-workflow.md +56 -0
- package/docs/agentic-patterns-to-port.md +273 -0
- package/docs/colour-pairings.md +41 -0
- package/docs/components.md +52 -0
- package/docs/copilot-ncrs-research-curator-agent.md +298 -0
- package/docs/custom-agents-usage.md +95 -0
- package/docs/design-actions-and-decisions.md +95 -0
- package/docs/gp-connect-real-data-to-production.md +66 -0
- package/docs/hippocampus-for-designers-and-researchers.md +261 -0
- package/docs/hippocampus-technical.md +373 -0
- package/docs/prompt-architecture-guardrails.md +39 -0
- package/docs/repeatable-research-round-ingestion.md +239 -0
- package/docs/research-knowledge-graph.md +552 -0
- package/docs/research-source-file-conventions.md +121 -0
- package/docs/source-model.md +252 -0
- package/index.js +47 -0
- package/lib/config.js +46 -0
- package/lib/context.js +59 -0
- package/lib/create-router.js +187 -0
- package/lib/discover-projects.js +45 -0
- package/lib/docs.js +72 -0
- package/lib/host-edit.js +151 -0
- package/lib/lint-knowledge.js +133 -0
- package/lib/load-knowledge.js +629 -0
- package/lib/package-paths.js +32 -0
- package/lib/paths.js +33 -0
- package/lib/resolve-root.js +84 -0
- package/lib/schema-version.js +96 -0
- package/lib/schema.js +346 -0
- package/package.json +70 -0
- package/scripts/audit-provenance.js +147 -0
- package/scripts/doctor.js +114 -0
- package/scripts/eject.js +51 -0
- package/scripts/import-blueprint-spreadsheet.js +242 -0
- package/scripts/import-design-actions.js +230 -0
- package/scripts/import-project-context.js +427 -0
- package/scripts/import-research-batch.js +160 -0
- package/scripts/import-research-deck.js +448 -0
- package/scripts/index-source-artefacts.js +261 -0
- package/scripts/init.js +232 -0
- package/scripts/install-skills.js +160 -0
- package/scripts/lib/artefact-text.js +347 -0
- package/scripts/lib/xlsx-grid.js +114 -0
- package/scripts/link-insights-to-needs.js +209 -0
- package/scripts/migrate-knowledge.js +73 -0
- package/scripts/promote-project-context-draft.js +125 -0
- package/scripts/promote-research-draft.js +236 -0
- package/scripts/smoke-routes.js +157 -0
- package/scripts/sync-project-context.js +236 -0
- package/scripts/sync-research-artefacts.js +294 -0
- package/scripts/triage-research-corpus.js +274 -0
- package/scripts/validate-knowledge.js +49 -0
- package/scripts/validate-skills.js +247 -0
- package/skills/COVERAGE-MATRIX.md +39 -0
- package/skills/README.md +77 -0
- package/skills/SKILL-FORMAT.md +100 -0
- package/skills/add-prototype-screen.skill.md +54 -0
- package/skills/add-scenario.skill.md +47 -0
- package/skills/add-user-needs.skill.md +51 -0
- package/skills/audit-knowledge-source.skill.md +47 -0
- package/skills/capture-route-review-pack.skill.md +52 -0
- package/skills/create-journey-from-scenario.skill.md +50 -0
- package/skills/deliver-service-slice.skill.md +65 -0
- package/skills/examples/README.md +15 -0
- package/skills/examples/non-copilot-research-ingestion.md +68 -0
- package/skills/examples/non-copilot-service-slice.md +64 -0
- package/skills/generate-service-slice.skill.md +57 -0
- package/skills/ingest-project-context.skill.md +58 -0
- package/skills/ingest-research-round.skill.md +60 -0
- package/skills/map-research-to-graph.skill.md +58 -0
- package/skills/record-accessibility-review.skill.md +52 -0
- package/skills/record-design-decision.skill.md +53 -0
- package/skills/review-research-import-draft.skill.md +45 -0
- package/skills/skills.json +464 -0
- package/skills/skills.schema.json +111 -0
- package/skills/structure-project-context-draft.skill.md +51 -0
- package/skills/structure-research-draft.skill.md +51 -0
- package/skills/triage-research-corpus.skill.md +51 -0
- package/skills/write-delivery-summary.skill.md +51 -0
- package/skills/write-route-review-summary.skill.md +55 -0
- package/views/hippocampus/_layout.html +45 -0
- package/views/hippocampus/blueprint-classic.html +39 -0
- package/views/hippocampus/blueprint.html +52 -0
- package/views/hippocampus/components/_blueprint-cell.njk +42 -0
- package/views/hippocampus/components/_blueprint-controls.njk +20 -0
- package/views/hippocampus/components/_blueprint-minimap.njk +14 -0
- package/views/hippocampus/components/_empty-state.njk +12 -0
- package/views/hippocampus/components/_journey-step.njk +21 -0
- package/views/hippocampus/components/_list.njk +23 -0
- package/views/hippocampus/components/_summary-card.njk +15 -0
- package/views/hippocampus/doc.html +11 -0
- package/views/hippocampus/docs.html +19 -0
- package/views/hippocampus/evidence-impact.html +53 -0
- package/views/hippocampus/graph.html +48 -0
- package/views/hippocampus/handover-empty.html +12 -0
- package/views/hippocampus/handover.html +56 -0
- package/views/hippocampus/index.html +46 -0
- package/views/hippocampus/journey-flow.html +31 -0
- package/views/hippocampus/journey.html +23 -0
- package/views/hippocampus/not-found.html +10 -0
- package/views/hippocampus/project.html +102 -0
- package/views/hippocampus/research.html +68 -0
- package/views/hippocampus/route-review.html +83 -0
- package/views/hippocampus/scenarios.html +56 -0
- package/views/hippocampus/trace.html +113 -0
- package/views/hippocampus/user-needs.html +32 -0
- package/views/hippocampus/user.html +157 -0
- package/views/hippocampus/users.html +32 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { createContextOrExit } = require('../lib/context')
|
|
4
|
+
const {
|
|
5
|
+
CURRENT_SCHEMA_VERSION,
|
|
6
|
+
META_FILENAME,
|
|
7
|
+
readMeta,
|
|
8
|
+
migrateInMemory,
|
|
9
|
+
writeMeta
|
|
10
|
+
} = require('../lib/schema-version')
|
|
11
|
+
|
|
12
|
+
// The loader migrates in memory and warns on every read. This is the command
|
|
13
|
+
// that makes it permanent, writing the upgraded collections and stamping
|
|
14
|
+
// _meta.json, so the warning stops and the files on disk match the code.
|
|
15
|
+
|
|
16
|
+
const ctx = createContextOrExit()
|
|
17
|
+
const dryRun = process.argv.includes('--dry-run')
|
|
18
|
+
|
|
19
|
+
const collectionFiles = {
|
|
20
|
+
users: 'users.json',
|
|
21
|
+
needs: 'needs.json',
|
|
22
|
+
scenarios: 'scenarios.json',
|
|
23
|
+
journeys: 'journeys.json',
|
|
24
|
+
screens: 'screens.json',
|
|
25
|
+
evidence: 'evidence.json',
|
|
26
|
+
decisions: 'decisions.json',
|
|
27
|
+
assumptions: 'assumptions.json',
|
|
28
|
+
researchRounds: 'research-rounds.json',
|
|
29
|
+
participants: 'participants.json',
|
|
30
|
+
insights: 'insights.json',
|
|
31
|
+
designActions: 'design-actions.json',
|
|
32
|
+
relationships: 'relationships.json'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function main () {
|
|
36
|
+
for (const project of ctx.requireProjects()) {
|
|
37
|
+
const sourceDir = path.join(project.dir, 'source')
|
|
38
|
+
const meta = readMeta(sourceDir)
|
|
39
|
+
|
|
40
|
+
if (meta.schemaVersion === CURRENT_SCHEMA_VERSION && !meta.implicit) {
|
|
41
|
+
console.log(`${project.name}: already at schemaVersion ${CURRENT_SCHEMA_VERSION}`)
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const raw = {}
|
|
46
|
+
for (const [key, filename] of Object.entries(collectionFiles)) {
|
|
47
|
+
const filePath = path.join(sourceDir, filename)
|
|
48
|
+
raw[key] = fs.existsSync(filePath) ? JSON.parse(fs.readFileSync(filePath, 'utf8')) : []
|
|
49
|
+
}
|
|
50
|
+
raw.project = JSON.parse(fs.readFileSync(path.join(sourceDir, 'project.json'), 'utf8'))
|
|
51
|
+
raw.projectContext = JSON.parse(fs.readFileSync(path.join(sourceDir, 'project-context.json'), 'utf8'))
|
|
52
|
+
|
|
53
|
+
const { data, version } = migrateInMemory(raw, meta)
|
|
54
|
+
|
|
55
|
+
if (dryRun) {
|
|
56
|
+
console.log(`${project.name}: would write schemaVersion ${version} and stamp ${META_FILENAME}`)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const [key, filename] of Object.entries(collectionFiles)) {
|
|
61
|
+
fs.writeFileSync(path.join(sourceDir, filename), JSON.stringify(data[key], null, 2) + '\n')
|
|
62
|
+
}
|
|
63
|
+
writeMeta(sourceDir, { generator: 'hippocampus migrate:knowledge' })
|
|
64
|
+
console.log(`${project.name}: migrated ${meta.schemaVersion} -> ${version}, ${META_FILENAME} stamped`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
main()
|
|
70
|
+
} catch (error) {
|
|
71
|
+
console.error(error.message)
|
|
72
|
+
process.exit(1)
|
|
73
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { spawnSync } = require('child_process')
|
|
4
|
+
const { ProjectContextImportDraftSchema } = require('../lib/schema')
|
|
5
|
+
const { createContextOrExit } = require('../lib/context')
|
|
6
|
+
const ctx = createContextOrExit()
|
|
7
|
+
const { knowledgeSourceDir, projectImportsDir } = ctx.paths
|
|
8
|
+
|
|
9
|
+
const projectContextPath = path.join(knowledgeSourceDir, 'project-context.json')
|
|
10
|
+
|
|
11
|
+
function main () {
|
|
12
|
+
const args = parseArgs(process.argv.slice(2))
|
|
13
|
+
if (!args.draft) {
|
|
14
|
+
throw new Error(`Usage: npm run promote:project-context-draft -- --draft ${path.relative(ctx.root, path.join(projectImportsDir, 'context-id.json'))} [--dry-run]`)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const draft = validateDraft(readJson(path.resolve(args.draft)))
|
|
18
|
+
const current = readJson(projectContextPath)
|
|
19
|
+
const proposed = mergeProjectContext(current, draft.projectContext)
|
|
20
|
+
|
|
21
|
+
console.log([
|
|
22
|
+
`users: +${newCount(current.users, draft.projectContext.users)}`,
|
|
23
|
+
`channels: +${newCount(current.channels, draft.projectContext.channels)}`,
|
|
24
|
+
`policies: +${newCount(current.policies, draft.projectContext.policies)}`,
|
|
25
|
+
`constraints: +${newCount(current.constraints, draft.projectContext.constraints)}`,
|
|
26
|
+
`risks: +${newCount(current.risks, draft.projectContext.risks)}`,
|
|
27
|
+
`milestones: +${newCount(current.milestones, draft.projectContext.milestones)}`,
|
|
28
|
+
`facts: +${newCount(current.facts, draft.projectContext.facts)}`,
|
|
29
|
+
`sourceArtefacts: +${newCount(current.sourceArtefacts, draft.projectContext.sourceArtefacts)}`
|
|
30
|
+
].join(', '))
|
|
31
|
+
|
|
32
|
+
if (args.dryRun) {
|
|
33
|
+
console.log('Dry run only. Source files were not changed.')
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
writeJson(projectContextPath, proposed)
|
|
38
|
+
|
|
39
|
+
const validation = spawnSync('npm', ['run', 'validate:knowledge'], {
|
|
40
|
+
cwd: ctx.root,
|
|
41
|
+
stdio: 'inherit'
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
if (validation.status !== 0) {
|
|
45
|
+
throw new Error('Promotion wrote project context, but knowledge validation failed. Review the promoted data.')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log('Project context draft promoted and knowledge validation passed.')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateDraft (draft) {
|
|
52
|
+
const result = ProjectContextImportDraftSchema.safeParse(draft)
|
|
53
|
+
if (result.success) return result.data
|
|
54
|
+
|
|
55
|
+
const issues = result.error.issues.map((issue) => {
|
|
56
|
+
const fieldPath = issue.path.length ? issue.path.join('.') : 'draft'
|
|
57
|
+
return `${fieldPath}: ${issue.message}`
|
|
58
|
+
})
|
|
59
|
+
throw new Error(`Project context draft does not match ProjectContextImportDraftSchema:\n- ${issues.join('\n- ')}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mergeProjectContext (current, incoming) {
|
|
63
|
+
return {
|
|
64
|
+
...current,
|
|
65
|
+
title: incoming.title || current.title,
|
|
66
|
+
summary: incoming.summary || current.summary,
|
|
67
|
+
purpose: incoming.purpose || current.purpose,
|
|
68
|
+
vision: incoming.vision || current.vision,
|
|
69
|
+
problemStatement: incoming.problemStatement || current.problemStatement,
|
|
70
|
+
serviceProposition: incoming.serviceProposition || current.serviceProposition,
|
|
71
|
+
users: mergeById(current.users, incoming.users),
|
|
72
|
+
channels: mergeById(current.channels, incoming.channels),
|
|
73
|
+
policies: mergeById(current.policies, incoming.policies),
|
|
74
|
+
constraints: mergeById(current.constraints, incoming.constraints),
|
|
75
|
+
risks: mergeById(current.risks, incoming.risks),
|
|
76
|
+
milestones: mergeById(current.milestones, incoming.milestones),
|
|
77
|
+
facts: mergeById(current.facts, incoming.facts),
|
|
78
|
+
sourceArtefacts: mergeById(current.sourceArtefacts, incoming.sourceArtefacts)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function mergeById (current, incoming) {
|
|
83
|
+
return Object.values(Object.fromEntries([
|
|
84
|
+
...current.map((item) => [item.id, item]),
|
|
85
|
+
...incoming.map((item) => [item.id, item])
|
|
86
|
+
]))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function newCount (current, incoming) {
|
|
90
|
+
const existingIds = new Set(current.map((item) => item.id))
|
|
91
|
+
return incoming.filter((item) => !existingIds.has(item.id)).length
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseArgs (args) {
|
|
95
|
+
const parsed = {}
|
|
96
|
+
for (let index = 0; index < args.length; index++) {
|
|
97
|
+
const arg = args[index]
|
|
98
|
+
if (!arg.startsWith('--')) continue
|
|
99
|
+
const [rawKey, value] = arg.slice(2).split('=', 2)
|
|
100
|
+
const key = camelCase(rawKey)
|
|
101
|
+
if (value !== undefined) {
|
|
102
|
+
parsed[key] = value
|
|
103
|
+
} else if (args[index + 1] && !args[index + 1].startsWith('--')) {
|
|
104
|
+
parsed[key] = args[index + 1]
|
|
105
|
+
index++
|
|
106
|
+
} else {
|
|
107
|
+
parsed[key] = true
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return parsed
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readJson (filePath) {
|
|
114
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeJson (filePath, data) {
|
|
118
|
+
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function camelCase (value) {
|
|
122
|
+
return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
main()
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { spawnSync } = require('child_process')
|
|
4
|
+
const { ResearchImportDraftSchema } = require('../lib/schema')
|
|
5
|
+
const { createContextOrExit } = require('../lib/context')
|
|
6
|
+
const ctx = createContextOrExit()
|
|
7
|
+
const { knowledgeSourceDir, researchImportsDir } = ctx.paths
|
|
8
|
+
|
|
9
|
+
const dataDir = knowledgeSourceDir
|
|
10
|
+
|
|
11
|
+
const collections = {
|
|
12
|
+
researchRounds: 'research-rounds.json',
|
|
13
|
+
participants: 'participants.json',
|
|
14
|
+
insights: 'insights.json',
|
|
15
|
+
designActions: 'design-actions.json',
|
|
16
|
+
relationships: 'relationships.json'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function main () {
|
|
20
|
+
const args = parseArgs(process.argv.slice(2))
|
|
21
|
+
if (!args.draft) {
|
|
22
|
+
throw new Error(`Usage: npm run promote:research-draft -- --draft ${path.relative(ctx.root, path.join(researchImportsDir, 'round-id.json'))} [--dry-run]`)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const draft = validateDraft(readJson(path.resolve(args.draft)))
|
|
26
|
+
const current = readCurrentCollections()
|
|
27
|
+
const reused = reconcileSharedRecords(current, draft)
|
|
28
|
+
const proposed = mergeCollections(current, draft)
|
|
29
|
+
validateNoDuplicateIds(current, draft)
|
|
30
|
+
validateDraftReferences(proposed)
|
|
31
|
+
|
|
32
|
+
if (reused.length) console.log(`Reusing existing records from earlier artefacts in this round: ${reused.join(', ')}`)
|
|
33
|
+
|
|
34
|
+
const counts = Object.keys(collections).map((key) => `${key}: +${(draft[key] || []).length}`)
|
|
35
|
+
console.log(`Promotion checked: ${counts.join(', ')}`)
|
|
36
|
+
|
|
37
|
+
if (args.dryRun) {
|
|
38
|
+
console.log('Dry run only. Source files were not changed.')
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const [key, filename] of Object.entries(collections)) {
|
|
43
|
+
writeJson(path.join(dataDir, filename), proposed[key])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const validation = spawnSync('npm', ['run', 'validate:knowledge'], {
|
|
47
|
+
cwd: ctx.root,
|
|
48
|
+
stdio: 'inherit'
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (validation.status !== 0) {
|
|
52
|
+
throw new Error('Promotion wrote source files, but knowledge validation failed. Review the promoted data.')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log('Research draft promoted and knowledge validation passed.')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A round is fieldwork, not a file, so several artefacts promote into the same
|
|
60
|
+
* round and meet the same people. Re-promoting an identical round record, or a
|
|
61
|
+
* participant already seen in a sibling artefact, is expected rather than a
|
|
62
|
+
* collision - the duplicate check should only fire on genuinely conflicting data.
|
|
63
|
+
*
|
|
64
|
+
* Returns the ids that were absorbed rather than added, so the run says so out loud.
|
|
65
|
+
*/
|
|
66
|
+
function reconcileSharedRecords (current, draft) {
|
|
67
|
+
const reused = []
|
|
68
|
+
|
|
69
|
+
const existingRounds = new Map(current.researchRounds.map((round) => [round.id, round]))
|
|
70
|
+
draft.researchRounds = (draft.researchRounds || []).filter((round) => {
|
|
71
|
+
const existing = existingRounds.get(round.id)
|
|
72
|
+
if (!existing) return true
|
|
73
|
+
if (existing.title !== round.title || existing.date !== round.date) {
|
|
74
|
+
throw new Error(`research round ${round.id} already exists with a different title or date. Reconcile the draft against hippocampus/source/research-rounds.json before promoting.`)
|
|
75
|
+
}
|
|
76
|
+
reused.push(`round ${round.id}`)
|
|
77
|
+
return false
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const existingParticipants = new Map(current.participants.map((participant) => [participant.id, participant]))
|
|
81
|
+
draft.participants = (draft.participants || []).filter((participant) => {
|
|
82
|
+
const existing = existingParticipants.get(participant.id)
|
|
83
|
+
if (!existing) return true
|
|
84
|
+
existing.roundIds = [...new Set([...existing.roundIds, ...participant.roundIds])]
|
|
85
|
+
// "Research participant" is the placeholder the importer falls back to, so a
|
|
86
|
+
// named role from any artefact in the round wins over it.
|
|
87
|
+
if (existing.role === 'Research participant' && participant.role !== 'Research participant') existing.role = participant.role
|
|
88
|
+
if (!existing.organisation && participant.organisation) existing.organisation = participant.organisation
|
|
89
|
+
reused.push(`participant ${participant.id}`)
|
|
90
|
+
return false
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
const draftIds = new Set([
|
|
94
|
+
...draft.researchRounds.map((item) => item.id),
|
|
95
|
+
...draft.participants.map((item) => item.id),
|
|
96
|
+
...current.researchRounds.map((item) => item.id),
|
|
97
|
+
...current.participants.map((item) => item.id)
|
|
98
|
+
])
|
|
99
|
+
const relationshipIds = new Set(current.relationships.map((item) => item.id))
|
|
100
|
+
draft.relationships = (draft.relationships || []).filter((link) => {
|
|
101
|
+
if (!relationshipIds.has(link.id)) return true
|
|
102
|
+
reused.push(`relationship ${link.id}`)
|
|
103
|
+
return false
|
|
104
|
+
})
|
|
105
|
+
void draftIds
|
|
106
|
+
|
|
107
|
+
return reused
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validateDraft (draft) {
|
|
111
|
+
const result = ResearchImportDraftSchema.safeParse(draft)
|
|
112
|
+
if (result.success) return result.data
|
|
113
|
+
|
|
114
|
+
const issues = result.error.issues.map((issue) => {
|
|
115
|
+
const fieldPath = issue.path.length ? issue.path.join('.') : 'draft'
|
|
116
|
+
return `${fieldPath}: ${issue.message}`
|
|
117
|
+
})
|
|
118
|
+
throw new Error(`Research draft does not match ResearchImportDraftSchema:\n- ${issues.join('\n- ')}`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseArgs (args) {
|
|
122
|
+
const parsed = {}
|
|
123
|
+
for (let index = 0; index < args.length; index++) {
|
|
124
|
+
const arg = args[index]
|
|
125
|
+
if (!arg.startsWith('--')) continue
|
|
126
|
+
const [rawKey, value] = arg.slice(2).split('=', 2)
|
|
127
|
+
const key = camelCase(rawKey)
|
|
128
|
+
if (value !== undefined) {
|
|
129
|
+
parsed[key] = value
|
|
130
|
+
} else if (args[index + 1] && !args[index + 1].startsWith('--')) {
|
|
131
|
+
parsed[key] = args[index + 1]
|
|
132
|
+
index++
|
|
133
|
+
} else {
|
|
134
|
+
parsed[key] = true
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return parsed
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readCurrentCollections () {
|
|
141
|
+
return Object.fromEntries(
|
|
142
|
+
Object.entries(collections).map(([key, filename]) => [key, readJson(path.join(dataDir, filename))])
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readJson (filePath) {
|
|
147
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function writeJson (filePath, data) {
|
|
151
|
+
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function mergeCollections (current, draft) {
|
|
155
|
+
return Object.fromEntries(
|
|
156
|
+
Object.keys(collections).map((key) => [key, [...current[key], ...(draft[key] || [])]])
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function validateNoDuplicateIds (current, draft) {
|
|
161
|
+
for (const key of Object.keys(collections)) {
|
|
162
|
+
const existingIds = new Set(current[key].map((item) => item.id))
|
|
163
|
+
const draftIds = new Set()
|
|
164
|
+
|
|
165
|
+
for (const item of draft[key] || []) {
|
|
166
|
+
if (existingIds.has(item.id)) throw new Error(`${key}: ${item.id} already exists in source data`)
|
|
167
|
+
if (draftIds.has(item.id)) throw new Error(`${key}: ${item.id} appears more than once in the draft`)
|
|
168
|
+
draftIds.add(item.id)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function validateDraftReferences (proposed) {
|
|
174
|
+
const ids = {
|
|
175
|
+
researchRounds: idSet(proposed.researchRounds),
|
|
176
|
+
participants: idSet(proposed.participants),
|
|
177
|
+
insights: idSet(proposed.insights),
|
|
178
|
+
designActions: idSet(proposed.designActions),
|
|
179
|
+
relationships: idSet(proposed.relationships)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const participant of proposed.participants) {
|
|
183
|
+
for (const roundId of participant.roundIds || []) requireId(ids.researchRounds, 'participant', participant.id, 'roundIds', roundId)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
for (const insight of proposed.insights) {
|
|
187
|
+
requireId(ids.researchRounds, 'insight', insight.id, 'roundId', insight.roundId)
|
|
188
|
+
for (const participantId of insight.participantIds || []) requireId(ids.participants, 'insight', insight.id, 'participantIds', participantId)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const action of proposed.designActions) {
|
|
192
|
+
for (const insightId of action.insightIds || []) requireId(ids.insights, 'design action', action.id, 'insightIds', insightId)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const entityIds = new Set([
|
|
196
|
+
...ids.researchRounds,
|
|
197
|
+
...ids.participants,
|
|
198
|
+
...ids.insights,
|
|
199
|
+
...ids.designActions,
|
|
200
|
+
...readCanonicalEntityIds()
|
|
201
|
+
])
|
|
202
|
+
|
|
203
|
+
for (const relationship of proposed.relationships) {
|
|
204
|
+
requireId(entityIds, 'relationship', relationship.id, 'from', relationship.from)
|
|
205
|
+
requireId(entityIds, 'relationship', relationship.id, 'to', relationship.to)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readCanonicalEntityIds () {
|
|
210
|
+
const filenames = [
|
|
211
|
+
'users.json',
|
|
212
|
+
'needs.json',
|
|
213
|
+
'scenarios.json',
|
|
214
|
+
'journeys.json',
|
|
215
|
+
'screens.json',
|
|
216
|
+
'evidence.json',
|
|
217
|
+
'decisions.json',
|
|
218
|
+
'assumptions.json'
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
return filenames.flatMap((filename) => readJson(path.join(dataDir, filename)).map((item) => item.id))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function idSet (items) {
|
|
225
|
+
return new Set(items.map((item) => item.id))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function requireId (ids, type, id, field, value) {
|
|
229
|
+
if (!ids.has(value)) throw new Error(`${type} ${id}: ${field} references missing id ${value}`)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function camelCase (value) {
|
|
233
|
+
return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
main()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const { spawn } = require('child_process')
|
|
2
|
+
const { createContextOrExit } = require('../lib/context')
|
|
3
|
+
const { listDocs } = require('../lib/docs')
|
|
4
|
+
const ctx = createContextOrExit()
|
|
5
|
+
|
|
6
|
+
const port = process.env.SMOKE_PORT || 4587
|
|
7
|
+
const baseUrl = `http://127.0.0.1:${port}`
|
|
8
|
+
// A cold Prototype Kit boot compiles sass before it listens, which on a fresh
|
|
9
|
+
// install is a good deal slower than the 15s this used to allow - and the
|
|
10
|
+
// failure looked like "your routes are broken" rather than "it had not finished
|
|
11
|
+
// starting".
|
|
12
|
+
const timeoutMs = Number(process.env.SMOKE_TIMEOUT_MS || 60000)
|
|
13
|
+
|
|
14
|
+
async function main () {
|
|
15
|
+
// Kept rather than discarded, so a server that dies during boot can say why
|
|
16
|
+
// instead of timing out silently.
|
|
17
|
+
let serverOutput = ''
|
|
18
|
+
const server = spawn(process.execPath, ['app.js'], {
|
|
19
|
+
cwd: ctx.root,
|
|
20
|
+
env: { ...process.env, PORT: String(port) },
|
|
21
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
22
|
+
})
|
|
23
|
+
server.stdout.on('data', (chunk) => { serverOutput += chunk })
|
|
24
|
+
server.stderr.on('data', (chunk) => { serverOutput += chunk })
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await waitForServer(server, () => serverOutput)
|
|
28
|
+
for (const project of ctx.requireProjects()) {
|
|
29
|
+
await smokeRoutes(buildRoutes(project.basePath, project.store.getKnowledge()))
|
|
30
|
+
}
|
|
31
|
+
console.log('Route smoke test passed')
|
|
32
|
+
} finally {
|
|
33
|
+
// The pipes have to go explicitly. Browsersync leaves descendants holding
|
|
34
|
+
// the other end, so killing the direct child is not enough - Node keeps the
|
|
35
|
+
// handles open and the process never exits, which looks exactly like a
|
|
36
|
+
// hung smoke test.
|
|
37
|
+
server.stdout.destroy()
|
|
38
|
+
server.stderr.destroy()
|
|
39
|
+
server.kill('SIGKILL')
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildRoutes (basePath, knowledge) {
|
|
44
|
+
const routes = new Set([
|
|
45
|
+
basePath,
|
|
46
|
+
`${basePath}/project`,
|
|
47
|
+
`${basePath}/users`,
|
|
48
|
+
`${basePath}/user-needs`,
|
|
49
|
+
`${basePath}/scenarios`,
|
|
50
|
+
`${basePath}/research`,
|
|
51
|
+
`${basePath}/graph`,
|
|
52
|
+
`${basePath}/trace`,
|
|
53
|
+
`${basePath}/handover`,
|
|
54
|
+
`${basePath}/docs`,
|
|
55
|
+
// The viewer serves these itself. If they 404 the pages load unstyled and
|
|
56
|
+
// the blueprint stops working, which is exactly the kind of breakage a
|
|
57
|
+
// smoke test should catch rather than a person noticing later.
|
|
58
|
+
`${basePath}/assets/hippocampus.css`,
|
|
59
|
+
`${basePath}/assets/blueprint.js`
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
for (const doc of listDocs()) {
|
|
63
|
+
routes.add(`${basePath}/docs/${doc.slug}`)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const journey of knowledge.journeys) {
|
|
67
|
+
routes.add(`${basePath}/journeys/${journey.id}`)
|
|
68
|
+
routes.add(`${basePath}/journeys/${journey.id}/blueprint`)
|
|
69
|
+
routes.add(`${basePath}/journeys/${journey.id}/blueprint/classic`)
|
|
70
|
+
routes.add(`${basePath}/journeys/${journey.id}/flow`)
|
|
71
|
+
routes.add(`${basePath}/journeys/${journey.id}/handover`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const user of knowledge.users) {
|
|
75
|
+
routes.add(`${basePath}/users/${user.id}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const screen of knowledge.screens) {
|
|
79
|
+
// Screens marked external live in the real NCRS prototype, not here, so
|
|
80
|
+
// this app has nothing to serve at their route.
|
|
81
|
+
if (screen.external) continue
|
|
82
|
+
routes.add(screen.route)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const evidence of knowledge.evidence) {
|
|
86
|
+
routes.add(`${basePath}/evidence/${evidence.id}/impact`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (knowledge.relationships.length) {
|
|
90
|
+
routes.add(`${basePath}/trace?entity=${knowledge.relationships[0].from}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return [...routes]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function waitForServer (server, output) {
|
|
97
|
+
const startedAt = Date.now()
|
|
98
|
+
|
|
99
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
100
|
+
if (server.exitCode !== null) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`The prototype exited with code ${server.exitCode} before it started listening.\n` +
|
|
103
|
+
indent(output())
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const response = await fetch(`${baseUrl}/hippocampus`)
|
|
109
|
+
if (response.ok) return
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// Keep polling until the Prototype Kit has finished booting.
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
await wait(250)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw new Error(
|
|
118
|
+
`The prototype did not start listening on ${baseUrl} within ${timeoutMs}ms.\n` +
|
|
119
|
+
'This command starts your app with PORT set, so if app.js hardcodes a port\n' +
|
|
120
|
+
'it will be running somewhere else. Otherwise raise SMOKE_TIMEOUT_MS.\n' +
|
|
121
|
+
indent(output())
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function smokeRoutes (routes) {
|
|
126
|
+
const failures = []
|
|
127
|
+
|
|
128
|
+
for (const route of routes) {
|
|
129
|
+
// `Connection: close` avoids a Node/undici keep-alive assertion bug
|
|
130
|
+
// (`assert(!this.paused)` in Parser.finish) seen with larger response
|
|
131
|
+
// bodies across many sequential fetches on Node 24.
|
|
132
|
+
const response = await fetch(`${baseUrl}${route}`, { headers: { Connection: 'close' } })
|
|
133
|
+
await response.arrayBuffer()
|
|
134
|
+
if (response.status !== 200) failures.push(`${route} returned ${response.status}`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (failures.length) {
|
|
138
|
+
throw new Error(`Route smoke test failed:\n- ${failures.join('\n- ')}`)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function indent (text) {
|
|
143
|
+
const trimmed = String(text || '').trim()
|
|
144
|
+
if (!trimmed) return ' (the prototype printed nothing)'
|
|
145
|
+
return trimmed.split('\n').slice(-12).map((line) => ` ${line}`).join('\n')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function wait (ms) {
|
|
149
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
main()
|
|
153
|
+
.then(() => process.exit(0))
|
|
154
|
+
.catch((error) => {
|
|
155
|
+
console.error(error.message)
|
|
156
|
+
process.exit(1)
|
|
157
|
+
})
|