@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
package/lib/docs.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { marked } = require('marked')
|
|
4
|
+
const packagePaths = require('./package-paths')
|
|
5
|
+
|
|
6
|
+
// Docs are served, not copied.
|
|
7
|
+
//
|
|
8
|
+
// Eight guides copied into a host repo are stale within a release, and the
|
|
9
|
+
// stale copy is the one people read - it is sitting in their editor, and the
|
|
10
|
+
// current one is not. Serving them from the viewer means the documentation a
|
|
11
|
+
// team reads always describes the version they have installed.
|
|
12
|
+
//
|
|
13
|
+
// Skills are the deliberate exception: an agent reads those as repo files, so
|
|
14
|
+
// `hippocampus skills install` copies them, with hashes so an upgrade can tell
|
|
15
|
+
// a stale copy from an edited one.
|
|
16
|
+
|
|
17
|
+
const TITLE_OVERRIDES = {
|
|
18
|
+
'hippocampus-for-designers-and-researchers': 'Hippocampus for designers and researchers',
|
|
19
|
+
'gp-connect-real-data-to-production': 'GP Connect: real data to production'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function slugOf (filename) {
|
|
23
|
+
return filename.replace(/\.md$/, '')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function titleOf (filename, contents) {
|
|
27
|
+
const slug = slugOf(filename)
|
|
28
|
+
if (TITLE_OVERRIDES[slug]) return TITLE_OVERRIDES[slug]
|
|
29
|
+
const heading = contents.match(/^#\s+(.+)$/m)
|
|
30
|
+
if (heading) return heading[1].trim()
|
|
31
|
+
return slug.replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function summaryOf (contents) {
|
|
35
|
+
const body = contents.replace(/^#\s+.+$/m, '').trim()
|
|
36
|
+
const paragraph = body.split(/\n\s*\n/).find((block) => block.trim() && !block.trim().startsWith('#'))
|
|
37
|
+
if (!paragraph) return ''
|
|
38
|
+
const text = paragraph.replace(/\s+/g, ' ').replace(/[*_`[\]]/g, '').trim()
|
|
39
|
+
return text.length > 200 ? `${text.slice(0, 197)}...` : text
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function listDocs () {
|
|
43
|
+
if (!fs.existsSync(packagePaths.docsDir)) return []
|
|
44
|
+
return fs.readdirSync(packagePaths.docsDir)
|
|
45
|
+
.filter((name) => name.endsWith('.md'))
|
|
46
|
+
.sort()
|
|
47
|
+
.map((name) => {
|
|
48
|
+
const contents = fs.readFileSync(path.join(packagePaths.docsDir, name), 'utf8')
|
|
49
|
+
return { slug: slugOf(name), title: titleOf(name, contents), summary: summaryOf(contents) }
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readDoc (slug) {
|
|
54
|
+
// The slug comes off a URL, so it is checked against the directory listing
|
|
55
|
+
// rather than joined onto a path and hoped for.
|
|
56
|
+
if (!/^[a-z0-9-]+$/.test(slug)) return null
|
|
57
|
+
const filePath = path.join(packagePaths.docsDir, `${slug}.md`)
|
|
58
|
+
if (!fs.existsSync(filePath)) return null
|
|
59
|
+
|
|
60
|
+
const contents = fs.readFileSync(filePath, 'utf8')
|
|
61
|
+
return {
|
|
62
|
+
slug,
|
|
63
|
+
title: titleOf(`${slug}.md`, contents),
|
|
64
|
+
html: marked(contents, { mangle: false, headerIds: true })
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function packageVersion () {
|
|
69
|
+
return require('../package.json').version
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { listDocs, readDoc, packageVersion }
|
package/lib/host-edit.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
const crypto = require('crypto')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
|
|
4
|
+
// Editing somebody else's app.js is the most dangerous thing this package does,
|
|
5
|
+
// so the rules are narrow on purpose:
|
|
6
|
+
//
|
|
7
|
+
// - every edit lives between markers, so it can be found and removed
|
|
8
|
+
// - the end marker carries a hash of the body, so a block somebody has
|
|
9
|
+
// customised is reported and left alone rather than silently overwritten
|
|
10
|
+
// - an insertion that does not match exactly once does nothing, and prints
|
|
11
|
+
// the lines to paste instead
|
|
12
|
+
//
|
|
13
|
+
// Half-editing a file leaves the owner worse off than not editing it and
|
|
14
|
+
// telling them what to type.
|
|
15
|
+
|
|
16
|
+
const START = (id) => `${id}:start v1 - managed by @hippo-digital/hippocampus`
|
|
17
|
+
const END = (id, hash) => `${id}:end ${hash}`
|
|
18
|
+
|
|
19
|
+
function hashBody (body) {
|
|
20
|
+
return crypto.createHash('sha256').update(body.trim()).digest('hex').slice(0, 12)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function markers (id, comment) {
|
|
24
|
+
const open = comment === 'js' ? '// >>> ' : '{# >>> '
|
|
25
|
+
const close = comment === 'js' ? '' : ' #}'
|
|
26
|
+
return {
|
|
27
|
+
start: `${open}${START(id)}${close}`,
|
|
28
|
+
endPrefix: comment === 'js' ? '// <<< ' : '{# <<< ',
|
|
29
|
+
endSuffix: close
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function findBlock (source, id, comment = 'js') {
|
|
34
|
+
const { start, endPrefix } = markers(id, comment)
|
|
35
|
+
const startIndex = source.indexOf(start)
|
|
36
|
+
if (startIndex === -1) return null
|
|
37
|
+
|
|
38
|
+
const endMarkerIndex = source.indexOf(`${endPrefix}${id}:end`, startIndex)
|
|
39
|
+
if (endMarkerIndex === -1) {
|
|
40
|
+
return { startIndex, endIndex: -1, body: null, declaredHash: null, unterminated: true }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const endLineEnd = source.indexOf('\n', endMarkerIndex)
|
|
44
|
+
const endIndex = endLineEnd === -1 ? source.length : endLineEnd + 1
|
|
45
|
+
const body = source.slice(startIndex + start.length, endMarkerIndex)
|
|
46
|
+
const endLine = source.slice(endMarkerIndex, endLineEnd === -1 ? source.length : endLineEnd)
|
|
47
|
+
const declaredHash = (endLine.match(/:end\s+([0-9a-f]{12})/) || [])[1] || null
|
|
48
|
+
|
|
49
|
+
return { startIndex, endIndex, body, declaredHash, unterminated: false }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Result is one of:
|
|
54
|
+
* { action: 'inserted' | 'updated' | 'unchanged', source }
|
|
55
|
+
* { action: 'customised' | 'unterminated', reason } - nothing written
|
|
56
|
+
*/
|
|
57
|
+
function upsertBlock (source, { id, body, comment = 'js', anchor }) {
|
|
58
|
+
const { start, endPrefix, endSuffix } = markers(id, comment)
|
|
59
|
+
const hash = hashBody(body)
|
|
60
|
+
const block = `${start}\n${body.trim()}\n${endPrefix}${id}:end ${hash}${endSuffix}\n`
|
|
61
|
+
|
|
62
|
+
const existing = findBlock(source, id, comment)
|
|
63
|
+
|
|
64
|
+
if (existing && existing.unterminated) {
|
|
65
|
+
return { action: 'unterminated', reason: `The ${id} block in this file has no end marker. Fix or remove it by hand.` }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (existing) {
|
|
69
|
+
const currentHash = hashBody(existing.body)
|
|
70
|
+
if (existing.declaredHash && existing.declaredHash !== currentHash) {
|
|
71
|
+
return {
|
|
72
|
+
action: 'customised',
|
|
73
|
+
reason: `The ${id} block in this file has been edited since it was written. Leaving it alone.`
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (currentHash === hash) return { action: 'unchanged', source }
|
|
77
|
+
return {
|
|
78
|
+
action: 'updated',
|
|
79
|
+
source: source.slice(0, existing.startIndex) + block + source.slice(existing.endIndex)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const insertAt = anchor ? anchor(source) : null
|
|
84
|
+
if (insertAt === null || insertAt === undefined) {
|
|
85
|
+
return { action: 'no-anchor', reason: 'Could not find a safe place to insert this block.' }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
action: 'inserted',
|
|
90
|
+
source: source.slice(0, insertAt) + block + source.slice(insertAt)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function removeBlock (source, id, comment = 'js') {
|
|
95
|
+
const existing = findBlock(source, id, comment)
|
|
96
|
+
if (!existing || existing.unterminated) return { action: 'absent', source }
|
|
97
|
+
return {
|
|
98
|
+
action: 'removed',
|
|
99
|
+
source: source.slice(0, existing.startIndex) + source.slice(existing.endIndex)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Adds an entry to a simple array of string literals - the kit's viewsPath and
|
|
105
|
+
* entryPoints. Applied only when the pattern matches exactly once and the array
|
|
106
|
+
* contains nothing but string literals. Anything cleverer than that is somebody
|
|
107
|
+
* else's code doing something we do not understand, and we should not be
|
|
108
|
+
* rewriting it.
|
|
109
|
+
*/
|
|
110
|
+
function addToStringArray (source, { name, entry }) {
|
|
111
|
+
if (source.includes(entry)) return { action: 'unchanged', source }
|
|
112
|
+
|
|
113
|
+
const pattern = new RegExp(`(const\\s+${name}\\s*=\\s*\\[)([^\\]]*)\\]`, 'g')
|
|
114
|
+
const matches = [...source.matchAll(pattern)]
|
|
115
|
+
if (matches.length !== 1) {
|
|
116
|
+
return { action: 'no-anchor', reason: `Expected exactly one \`const ${name} = [...]\`, found ${matches.length}.` }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const [full, open, inner] = matches[0]
|
|
120
|
+
const items = inner.split(',').map((item) => item.trim()).filter(Boolean)
|
|
121
|
+
const allStringLiterals = items.every((item) => /^(['"]).*\1$/.test(item) || item.startsWith('//'))
|
|
122
|
+
if (!allStringLiterals) {
|
|
123
|
+
return { action: 'no-anchor', reason: `\`${name}\` is not a plain array of strings, so it is not safe to edit automatically.` }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const indent = (inner.match(/\n(\s+)/) || [, ' '])[1]
|
|
127
|
+
const replacement = `${open}${inner.replace(/\s*$/, '')},\n${indent}${entry}\n]`
|
|
128
|
+
return { action: 'updated', source: source.replace(full, replacement) }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function backup (filePath) {
|
|
132
|
+
const backupPath = `${filePath}.hippocampus-backup`
|
|
133
|
+
if (!fs.existsSync(backupPath)) fs.copyFileSync(filePath, backupPath)
|
|
134
|
+
return backupPath
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function diff (before, after, filePath) {
|
|
138
|
+
const beforeLines = before.split('\n')
|
|
139
|
+
const afterLines = after.split('\n')
|
|
140
|
+
const out = [`--- ${filePath}`, `+++ ${filePath}`]
|
|
141
|
+
let i = 0
|
|
142
|
+
let j = 0
|
|
143
|
+
while (i < beforeLines.length || j < afterLines.length) {
|
|
144
|
+
if (beforeLines[i] === afterLines[j]) { i++; j++; continue }
|
|
145
|
+
if (afterLines.includes(beforeLines[i]) === false && i < beforeLines.length) { out.push(`-${beforeLines[i]}`); i++; continue }
|
|
146
|
+
out.push(`+${afterLines[j]}`); j++
|
|
147
|
+
}
|
|
148
|
+
return out.join('\n')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { upsertBlock, removeBlock, addToStringArray, findBlock, hashBody, backup, diff }
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const { recordSchemas } = require('./schema')
|
|
2
|
+
|
|
3
|
+
// Parsing answers "is this loadable?". Linting answers the question a team
|
|
4
|
+
// actually has: "what is thin, and what is missing?" Keeping them apart is what
|
|
5
|
+
// lets a freshly initialised project boot with nothing in it while still being
|
|
6
|
+
// told, every time, what it has not filled in yet.
|
|
7
|
+
//
|
|
8
|
+
// error - referential integrity. The graph is broken.
|
|
9
|
+
// warning - the old min-1 rules, rephrased as onboarding prompts, plus
|
|
10
|
+
// anything that looks like a mistake rather than a gap.
|
|
11
|
+
// info - coverage. Nothing is wrong; something is thin.
|
|
12
|
+
//
|
|
13
|
+
// The info tier is the point. "51 insights have no need link" was something
|
|
14
|
+
// somebody happened to notice once; it should be a line of standing output.
|
|
15
|
+
|
|
16
|
+
const ONBOARDING = {
|
|
17
|
+
users: 'No users yet. Start with who the service is for - everything else hangs off a user.',
|
|
18
|
+
needs: 'No user needs yet. A need says who wants what, and why.',
|
|
19
|
+
scenarios: 'No scenarios yet. A scenario is the situation a user is in when a need bites.',
|
|
20
|
+
journeys: 'No journeys yet. A journey is how a scenario plays out, step by step.',
|
|
21
|
+
screens: 'No screens yet. A screen is a route this prototype serves, linked to the needs it meets.',
|
|
22
|
+
evidence: 'No evidence yet. Evidence is what makes a need a finding rather than an opinion.',
|
|
23
|
+
decisions: 'No decisions yet. A decision records an option that was weighed and what happened to it.',
|
|
24
|
+
assumptions: 'No assumptions yet. An assumption is a belief the design rests on that nobody has tested.'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function lintKnowledge (knowledge) {
|
|
28
|
+
const warnings = []
|
|
29
|
+
const infos = []
|
|
30
|
+
const index = knowledge.index || {}
|
|
31
|
+
|
|
32
|
+
for (const [collection, message] of Object.entries(ONBOARDING)) {
|
|
33
|
+
if (!(knowledge[collection] || []).length) {
|
|
34
|
+
warnings.push({ rule: 'empty-collection', collection, message })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Passthrough schemas mean a misspelled key is kept rather than rejected.
|
|
39
|
+
// That is the right trade for forward compatibility, but somebody has to say
|
|
40
|
+
// "you wrote titel" or the field silently does nothing forever.
|
|
41
|
+
for (const [collection, schema] of Object.entries(recordSchemas)) {
|
|
42
|
+
const known = new Set(Object.keys(schema.shape))
|
|
43
|
+
for (const record of knowledge[collection] || []) {
|
|
44
|
+
for (const key of Object.keys(record)) {
|
|
45
|
+
if (known.has(key)) continue
|
|
46
|
+
warnings.push({
|
|
47
|
+
rule: 'unrecognised-field',
|
|
48
|
+
collection,
|
|
49
|
+
id: record.id,
|
|
50
|
+
message: `${collection} ${record.id}: unrecognised field "${key}" - kept, but check the spelling`
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const legacyActionLinks = (knowledge.relationships || []).filter((r) => r.type === 'leads-to-action')
|
|
57
|
+
if (legacyActionLinks.length) {
|
|
58
|
+
warnings.push({
|
|
59
|
+
rule: 'legacy-relationship-type',
|
|
60
|
+
message:
|
|
61
|
+
`${legacyActionLinks.length} relationships still use leads-to-action (insight -> action). ` +
|
|
62
|
+
'Dependents point at what they rely on, so these should be responds-to-insight ' +
|
|
63
|
+
'(action -> insight) or traversal from a screen stops at the insight.'
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const needsWithoutEvidence = (knowledge.needs || []).filter((need) => !(need.evidenceIds || []).length)
|
|
68
|
+
if (needsWithoutEvidence.length) {
|
|
69
|
+
infos.push({
|
|
70
|
+
rule: 'need-without-evidence',
|
|
71
|
+
count: needsWithoutEvidence.length,
|
|
72
|
+
ids: needsWithoutEvidence.map((need) => need.id),
|
|
73
|
+
message: `${needsWithoutEvidence.length} of ${knowledge.needs.length} needs cite no evidence`
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const needLinked = new Set()
|
|
78
|
+
for (const relationship of knowledge.relationships || []) {
|
|
79
|
+
if (index.insights && index.insights[relationship.from] && index.needs && index.needs[relationship.to]) {
|
|
80
|
+
needLinked.add(relationship.from)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const unlinkedInsights = (knowledge.insights || []).filter((insight) => !needLinked.has(insight.id))
|
|
84
|
+
if (unlinkedInsights.length) {
|
|
85
|
+
infos.push({
|
|
86
|
+
rule: 'insight-without-need',
|
|
87
|
+
count: unlinkedInsights.length,
|
|
88
|
+
ids: unlinkedInsights.map((insight) => insight.id),
|
|
89
|
+
message:
|
|
90
|
+
`${unlinkedInsights.length} of ${knowledge.insights.length} insights link to no user need - ` +
|
|
91
|
+
'usually a gap in the needs layer rather than a bad insight'
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const roundsWithInsights = new Set((knowledge.insights || []).map((insight) => insight.roundId))
|
|
96
|
+
const emptyRounds = (knowledge.researchRounds || []).filter((round) => !roundsWithInsights.has(round.id))
|
|
97
|
+
if (emptyRounds.length) {
|
|
98
|
+
infos.push({
|
|
99
|
+
rule: 'round-without-insights',
|
|
100
|
+
count: emptyRounds.length,
|
|
101
|
+
ids: emptyRounds.map((round) => round.id),
|
|
102
|
+
message: `${emptyRounds.length} of ${knowledge.researchRounds.length} research rounds produced no insights`
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const screensWithoutNeeds = (knowledge.screens || []).filter((screen) => !(screen.needIds || []).length)
|
|
107
|
+
if (screensWithoutNeeds.length) {
|
|
108
|
+
infos.push({
|
|
109
|
+
rule: 'screen-without-need',
|
|
110
|
+
count: screensWithoutNeeds.length,
|
|
111
|
+
ids: screensWithoutNeeds.map((screen) => screen.id),
|
|
112
|
+
message: `${screensWithoutNeeds.length} of ${knowledge.screens.length} screens trace back to no user need`
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const defaultPriority = (knowledge.needs || []).filter((need) => need.priority === 'medium')
|
|
117
|
+
if (defaultPriority.length && knowledge.needs.length) {
|
|
118
|
+
const share = Math.round((defaultPriority.length / knowledge.needs.length) * 100)
|
|
119
|
+
if (share >= 50) {
|
|
120
|
+
infos.push({
|
|
121
|
+
rule: 'priority-unset',
|
|
122
|
+
count: defaultPriority.length,
|
|
123
|
+
message:
|
|
124
|
+
`${defaultPriority.length} of ${knowledge.needs.length} needs (${share}%) sit at priority "medium" - ` +
|
|
125
|
+
'likely the import default rather than a decision, so priority cannot sequence design work yet'
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { warnings, infos }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = { lintKnowledge }
|