B2 Semantic and schema
One block model, shared by the service, the rendered page and the tests, and the contract that describes it.
T2.1 Share the semantic module
- Wave: 1
- Depends on: none
- Size: S
- Design: state-machine.md (block identity)
- Touches:
semantic.js(moved fromservice/src/semantic.js),service/src/gitlab.js,service/Dockerfile,index.js,index.d.ts,package.json(files),test/service.test.js(import path),test/semantic.test.js(new),README.md(one line under “Stable API surface”) - Why: the remark plugin, the client and the tests need the block model from the package root. It becomes a package module that the service imports.
Steps
git mv service/src/semantic.js semantic.js.service/src/gitlab.js:import { … } from '../../semantic.js';.test/service.test.js: update its import to../semantic.js.service/Dockerfile: the service’s working directory is/appand its source is at/app/src, so../../semantic.jswould resolve outside/app. Change the runtime stage to copy the service under/app/serviceand the shared module to/app/semantic.js:COPY --chown=node:node semantic.js ./semantic.js,COPY --chown=node:node service/package.json ./service/package.json,COPY --chown=node:node service/src ./service/src, movenode_modulesto./service/node_modules, and setCMD ["node", "service/src/server.js"]. The health check path is unchanged.package.jsonfiles: add"semantic.js".index.js:export { parseDocumentBlocks, diffDocumentBlocks, detectLayerConflicts } from './semantic.js';index.d.ts: declareSemanticBlock,SemanticOperation,LayerConflicttypes matchingcontracts/openapi.yamland the three function signatures.npm run build:containermust succeed and the container’s health check must pass: run it with the T0.3 environment variables (or any valid set) andcurl http://127.0.0.1:8787/v1/health.
Acceptance
test/semantic.test.js:
semantic exports are reachable from the package rootsemantic block ids for the fixture versions match the canonical table(V1, V3 againstblockIdsfromtest/fixtures/mock-gitlab/data.js; if T0.1 is not done yet, inline the expected ids from fixture-data.md and switch to the import when it lands)semantic diff of the fixture versions yields the canonical operations(the fourdiffDocumentBlocksexpectations in fixture-data.md)
npm test 2>&1 | grep -c "^✔ semantic" # prints 3
npm pack --dry-run 2>&1 | grep -c " semantic.js" # prints 1
npm run check:service # exit 0
npm run build:container && docker run --rm -d --name redline-t21 -p 8787:8787 --env-file <a valid env file> fio-redline:local && sleep 3 && curl -sf http://127.0.0.1:8787/v1/health && docker rm -f redline-t21 # health JSON printed
(If Docker is unavailable locally, record it in STATUS.md; the pipeline’s
container-build-push job on main is the backstop.)
T2.2 Remark plugin for block ids
- Wave: 2
- Depends on: T2.1
- Size: M
- Design: state-machine.md (block identity)
- Touches:
remark-redline-blocks.js(new),index.js(integration optionblocks),index.d.ts,package.json(files,exports, devDependency@astrojs/markdown-remarkexact),test/remark-blocks.test.js(new),test/fixtures/site/astro.config.mjs(gitHistory({ blocks: true })),README.md(short section “Block ids on rendered pages”) - Why: comments, redlines and changed-since all need rendered elements to carry the same ids the service computes from source. Today
GitComments.astroinventsdom:p:Nids that match nothing.
Steps
remark-redline-blocks.jsdefault export is a remark plugin; it imports from./semantic.js. In the transformer:const blocks = parseDocumentBlocks(String(file.value)); index blocks bystartLine. Walktree.children; forlistnodes walk theirlistItemchildren instead of the list. For each node, look upblocks.get(node.position.start.line); if absent, fall back to the first unused block whosefingerprintequals the fingerprint of the node’s source slice (file.value.slice(node.position.start.offset, node.position.end.offset)) computed by the same function assemantic.js(exportfingerprintBlock(type, content)fromsemantic.jsfor this). On a match setnode.data = { ...node.data, hProperties: { ...node.data?.hProperties, 'data-redline-block': block.id } }and mark the block used. Never assign one block id twice.index.jsintegration: whenoptions.blocks === true, inastro:config:setupcallupdateConfig({ markdown: { remarkPlugins: [remarkRedlineBlocks] } }).blocksmust not reach the serialised virtual module options (strip it).package.json: add"./remark-redline-blocks.js": "./remark-redline-blocks.js"toexportsand the file tofiles.- Fixture site:
gitHistory({ blocks: true }).
Acceptance
test/remark-blocks.test.js using createMarkdownProcessor from @astrojs/markdown-remark:
remark plugin stamps every heading paragraph and code block with its semantic id(render V3; assert the HTML containsdata-redline-block="<id>"exactly once for each of the four V3 ids)remark plugin ids match parseDocumentBlocks for a document with frontmatter lists and code(a 30-line sample with frontmatter, two heading levels, a bullet list of three items and a fenced block; strip the frontmatter with/^---\n[\s\S]*?\n---\n/before rendering, as Astro does, and compare againstparseDocumentBlocks(fullSource).filter((block) => block.type !== 'frontmatter'); every remaining block of type heading, paragraph, list-item, code appears once in the HTML with its id)remark plugin never assigns one id twice(two identical paragraphs; ids…:paragraphand…:paragraph-2both present)
npm test 2>&1 | grep -c "^✔ remark plugin" # prints 3
npm run fixture:build && grep -c 'data-redline-block="title:paragraph:paragraph"' test/fixtures/site/dist/index.html # prints 1
T2.3 Block history in the service
- Wave: 2
- Depends on: T2.1, T0.1
- Size: M
- Design: state-machine.md (
blockChanges,blockHistory), fixture-data.md - Touches:
service/src/gitlab.js,test/block-history.test.js(new) - Why: “changed since” needs, per block, the commits that changed it.
Steps
- In
getDocumentHistory, aftercommitsare mapped, compute for each commit (in parallel withmapLimit(..., 4, ...)):after = getFileText(sha)andbefore = parent ? getFileText(parent) : '', wheregetFileText(ref)callsgetFilewithcacheTtl: 86400and returns''on a 404ServiceError. ExtendgetFilewith a fifth argument{ cacheTtl = 60 }for this; existing callers are unchanged.blockChanges = diffDocumentBlocks(before, after).operations.map(({ type, blockId }) => ({ blockId, type })).- On a
413(document-too-large) setblockChanges = null. - The parent is
commit.parent_ids?.[0]; the raw commit list already has it, so carryparentIdsthrough the mapped commit.
- Build
blockHistoryfrom the commits in order (they are newest first): for each commit with a non-nullblockChanges, push{ sha, committedAt, type }ontoblockHistory[blockId]. - Return
blockHistorybesidecommitsin the history object. The/documents/stateroute already embedshistory, so no route change.
Acceptance
test/block-history.test.js with the shared mock:
document history carries block changes per commit(c1 → two inserts, c2 → one replace oftitle:paragraph:paragraph, c3 → two inserts)document history aggregates block history newest first(deep-equal to the expectedblockHistoryin fixture-data.md ignoringcommittedAtordering already implied)document state exposes block history(GET /v1/sites/demo/documents/state→history.blockHistoryhas the four keys)block changes are null for an oversized document(mock returns a file overMAX_DOCUMENT_BYTESfor one sha)
npm test 2>&1 | grep -c "^✔ document history\|^✔ document state exposes\|^✔ block changes are null" # prints 4
T2.4 Failure classification
- Wave: 2
- Depends on: T0.5
- Size: S
- Design: state-machine.md (classification, messages)
- Touches:
client.js,index.d.ts,index.js(re-export),test/client-state.test.js(new) - Why: one function decides which of the three failure states applies; every component and every test uses it.
Steps
client.js: exportREDLINE_STATES(array of the eight state names),REDLINE_MESSAGES(object, exact strings from the design;liveanderrorare functions oftimeandcode),classifyRedlineFailure({ error, response, payload })andderiveRedlineState(payload)exactly as the design specifies.RedlineClientError: add akindproperty set byclassifyRedlineFailurewhere the client throws.index.jsre-exports the four names.index.d.tsdeclares them.
Acceptance
test/client-state.test.js:
classifies a thrown TypeError as offline-networkclassifies 502 gitlab-unreachable as offline-gitlabclassifies 401 as unauthenticatedclassifies other failures as errorderives connect-required from a payload without a GitLab sessionderives live from a payload with a GitLab sessionmessages exist for every state
npm test 2>&1 | grep -c "^✔ classifies\|^✔ derives\|^✔ messages exist" # prints 7
T2.5 Contract tests
- Wave: 3
- Depends on: T0.5, T2.3
- Size: M
- Design: state-machine.md (schema additions)
- Touches:
contracts/openapi.yaml,test/contract.test.js(new),package.json(devDependenciesajv,ajv-formats,yaml, exact) - Why: the schema is the written form of the data model. Tests must fail when the service drifts from it.
Steps
openapi.yaml: add aHealthschema withgitlab: { reachable, checkedAt, status }and reference it from/v1/health; addblockChangestoCommit(type: [array, 'null'], items{ blockId, type: enum }); addblockHistorytoHistory(object withadditionalPropertiesarray of{ sha, committedAt, type }); addgitlab-unreachableto the errorcodedescription; add a note onIDENTITY_MODEininfo.description. Bumpinfo.versionto1.1.0.test/contract.test.js: load the YAML, createnew Ajv({ strict: false, allErrors: true })withaddFormats,ajv.addSchema({ $id: 'openapi', components: doc.components }), and validate with{ $ref: 'openapi#/components/schemas/<Name>' }. Produce the payloads by calling the service handler with the shared mock.
Acceptance
health payload validates against the Health schemadocument state payload validates against the DocumentState schemaerror payload validates against the Error schemadocument state payload with an unknown required field fails validation(deletehistoryfrom the payload; expectvalid === false, proving the validator bites)
npm test 2>&1 | grep -c "validates against\|fails validation" # prints 4
T2.6 Word-level diff
- Wave: 2
- Depends on: T2.1
- Size: S
- Design: fixture-data.md (expected
diffWords) - Touches:
semantic.js,index.js,index.d.ts,test/semantic.test.js - Why: inline redline renders a replaced block as struck and inserted words, not two whole paragraphs.
Steps
- Export
diffWords(before, after)from the rootsemantic.js. Tokenise withtext.split(/(\s+)/).filter(Boolean)so whitespace tokens are kept. Run the same LCS aslcsMatchesover token strings (refactorlcsMatchesto accept a key function rather than duplicating it). Emit segments{ type: 'equal' | 'delete' | 'insert', text }in order, merging adjacent segments of the same type. When either side exceeds 2,000 tokens return[{ type: 'delete', text: before }, { type: 'insert', text: after }]. - Re-export from
index.js; declare inindex.d.ts.
Acceptance
Add to test/semantic.test.js:
word diff of the fixture paragraphs matches the canonical segmentsword diff merges adjacent segments and preserves whitespace("a b c"→"a x y c"givesequal "a ",delete "b",insert "x y",equal " c")word diff falls back to whole-block segments over the token limit
npm test 2>&1 | grep -c "^✔ word diff" # prints 3