aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorRalph Amissah <ralph.amissah@gmail.com>2026-09-21 16:12:56 -0400
committerRalph Amissah <ralph.amissah@gmail.com>2026-09-23 08:22:15 -0400
commit8b81e9a11c7af642c4d0d8584a9cb348cc887d6e (patch)
tree8ba9c356ff71bf18f5623c22d30a8b2513321cfe
parentuid and paths separator "~" in place of ":" (diff)
ocda: warn if heading claims reserved segment name
spine automatically builds some segments, including: (toc, endnotes, glossary, bibliography, bookindex, blurb, _the_title) these are now identified as reserved names and a user is now warned if any of these names have been manually assigned to a heading by markup. A document still builds but to disambiguate the ocn of the heading is attached to the markup (reserved) name and this is seeded before a document not after. It is read off the finished abstraction rather than reported by the parser, and beside the ocn alignment check for the same reason: it is a statement about a document rather than a step in building one. WARNING reserved segment name: the_autonomous_contract... [en] heading 1 at ocn 135 asks for "endnotes", which spine gives its own generated section it is named "endnotes-135" instead; ... A warning: the document is correct and complete and the name it ends up with works. --strict makes it a failure for the run, as it does for ocn alignment, and by the same reasoning: the outputs are written and can be looked at, and the exit status is taken at the end. Two of the thirty-six sample documents have reserved segment names, "1~endnotes" heading. Output is unchanged: nothing here touches the abstraction. (assisted by Claude-Code)
-rw-r--r--org/ocda.org12
-rw-r--r--org/ocda_functions.org15
-rw-r--r--org/ocda_reserved.org212
-rw-r--r--org/spine.org58
-rw-r--r--org/tests_for_document_abstraction_shell_scripts.org194
-rw-r--r--src/sisudoc/ocda/meta/metadoc_from_src.d12
-rw-r--r--src/sisudoc/ocda/meta/metadoc_from_src_functions.d15
-rw-r--r--src/sisudoc/ocda/meta/reserved_names.d206
-rw-r--r--src/sisudoc/spine.d58
-rwxr-xr-xtest/run-tests.sh29
-rwxr-xr-xtest/test-run-order.sh142
11 files changed, 903 insertions, 50 deletions
diff --git a/org/ocda.org b/org/ocda.org
index a9f0368..b84b3ef 100644
--- a/org/ocda.org
+++ b/org/ocda.org
@@ -187,6 +187,18 @@ scope(exit) {
anchor_tag = "";
_heading_anchor_tags_seen = _reserved_heading_anchor_tags();
}
+/+ ↓ seeded here, on the way in, which is the seeding that matters.
+ .
+ The two assignments in scope(exit) and at the tail of this function run
+ after a document has been abstracted, so they seed the *next* one. The
+ first document of a run had nothing before it and began with the set as
+ declared, empty: it alone was not held to the reserved names, and a heading
+ of its own claiming "endnotes" kept the name of the generated section.
+ Which document that was depended on what else was in the run, so a
+ document's abstraction depended on its company, and a single document
+ re-parsed to check an artefact made in a collection run disagreed with it.
++/
+_heading_anchor_tags_seen = _reserved_heading_anchor_tags();
mixin spineNode;
int[string] node_para_int_ = node_metadata_para_int;
string[string] node_para_str_ = node_metadata_para_str;
diff --git a/org/ocda_functions.org b/org/ocda_functions.org
index db6d63b..a5b60fa 100644
--- a/org/ocda_functions.org
+++ b/org/ocda_functions.org
@@ -70,16 +70,15 @@ bool[string] _heading_anchor_tags_seen;
these names means the document's heading is disambiguated the same
way a repeated anchor tag is, and the generated section keeps the
name that the table of contents links to.
+ .
+ The list itself is in sisudoc.ocda.meta.reserved_names, with the
+ check that reports such a heading to its author. Two lists would be
+ two answers to what spine has taken.
+/
bool[string] _reserved_heading_anchor_tags() {
- bool[string] _reserved;
- foreach (_name; [
- "toc", "endnotes", "glossary", "bibliography", "bookindex", "blurb",
- "_the_title",
- ]) {
- _reserved[_name] = true;
- }
- return _reserved;
+ import sisudoc.ocda.meta.reserved_names;
+ mixin spineReservedNames _rn;
+ return _rn.reservedHeadingAnchorTags();
}
string lev_anchor_tag;
string[string][string] tag_assoc;
diff --git a/org/ocda_reserved.org b/org/ocda_reserved.org
new file mode 100644
index 0000000..eddf455
--- /dev/null
+++ b/org/ocda_reserved.org
@@ -0,0 +1,212 @@
+-*- mode: org -*-
+#+TITLE: sisudoc spine (doc_reform) object-centric document abstraction
+#+DESCRIPTION: documents - structuring, publishing in multiple formats & search
+#+SUMMARY: process markup document, create document abstraction
+#+FILETAGS: :spine:abstraction:
+#+AUTHOR: Ralph Amissah
+#+EMAIL: [[mailto:ralph.amissah@gmail.com][ralph.amissah@gmail.com]]
+#+COPYRIGHT: Copyright (C) 2015 (continuously updated, current 2026) Ralph Amissah
+#+LANGUAGE: en
+#+STARTUP: content hideblocks hidestars noindent entitiespretty
+#+PROPERTY: header-args+ :eval never-export :exports code
+#+PROPERTY: header-args+ :noweb yes :padline no
+#+PROPERTY: header-args+ :results silent :cache no
+#+PROPERTY: header-args+ :mkdirp yes
+#+OPTIONS: H:3 num:nil toc:t \n:t ::t |:t ^:nil -:t f:t *:t
+- magic single double-quote → " ← FIX changes hilighting behavior (occuring
+ after it) in org document. INVESTIGATE (org-mode CONFIG?) FIND & FIX
+
+- [[./doc-reform.org][doc-reform.org]] [[./][org/]]
+
+* reserved file names
+
+#+HEADER: :tangle "../src/sisudoc/ocda/meta/reserved_names.d"
+#+HEADER: :noweb yes
+#+BEGIN_SRC d
+<<doc_header_including_copyright_and_license>>
+/++
+ the segment names spine keeps for itself<br><br>
+
+ the one list of them, and the check that says when a document's own
+ heading has claimed one<br><br>
+
+ [sisudoc.ocda.meta.reserved_names]
++/
+module sisudoc.ocda.meta.reserved_names;
+@safe:
+/+ ↓ spine names the sections it generates, these names are reserved.
+ .
+ The table of contents, the gathered endnotes, the glossary, the
+ bibliography, the book index and the blurb are not in the markup: spine
+ makes them, and gives each a segment name of its own. A document whose
+ own heading asks for one of those names ("1~endnotes Endnote") is asking
+ for a name that is already taken, and before this was noticed both wrote
+ into the same file: the generated section and the author's heading, with
+ the navigation listing that file twice, the second time behind its own
+ earlier entry in reading order.
+ .
+ The parser therefore seeds the set of heading anchor names it has seen
+ with these before it reads a document, so that a heading claiming one is
+ disambiguated by ocn suffix the same way a repeated anchor tag is, and
+ the generated section keeps the name the table of contents links to.
+ .
+ Two things live here rather than in the parser:
+ .
+ the list so that the parser's seeding and the check below cannot
+ come to hold different ideas of what is reserved;
+ .
+ the check which runs over a finished abstraction, names the
+ headings that were renamed, and is what tells an author
+ that a heading of theirs is not called what they wrote.
+ A warning: the document is correct and complete, and the
+ name it ends up with works. --strict makes it a failure,
+ for the run that is meant to be publishable.
+ .
+ The renaming is not something the author has to accept. A heading given
+ any other anchor name ("1~my-notes Endnote") keeps it, and the warning
+ goes away.
++/
+/+ ↓ mixed in as a named mixin (mixin spineReservedNames _name;) and its
+ imports are inside its functions rather than at template scope: what a
+ mixin template declares at its own scope is visible in the scope it is
+ mixed into, and an import there can hijack a name the host was already
+ resolving by UFCS.
++/
+template spineReservedNames() {
+ /+ ↓ the names spine makes for itself, in one place.
+ _the_title is the head section's own; the rest are the generated
+ sections, and are the section keys of the abstraction.
+ +/
+ string[] reservedHeadingAnchorNames() {
+ return [
+ "toc", "endnotes", "glossary", "bibliography", "bookindex", "blurb",
+ "_the_title",
+ ];
+ }
+ /+ ↓ the same, as the set the parser seeds itself with +/
+ bool[string] reservedHeadingAnchorTags() {
+ bool[string] _reserved;
+ foreach (_name; reservedHeadingAnchorNames()) { _reserved[_name] = true; }
+ return _reserved;
+ }
+ /+ ↓ one heading that asked for a name spine had already taken +/
+ struct ST_ReservedNameUse {
+ string section; // where in the abstraction it sits
+ string reserved; // the name it asked for
+ string anchor; // the name it was given instead
+ string level; // as marked up, :A :B 1 2 ...
+ int ocn;
+ }
+ /+ ↓ was this heading renamed out of the way of a reserved name?
+ .
+ Read off the anchor rather than reported by the parser: the parser
+ renames by appending the object's number, so "endnotes" becomes
+ "endnotes-135" on the object whose ocn is 135, and that pairing is what
+ identifies it. Requiring the number to be the object's own is what keeps
+ a heading an author really did call "endnotes-135" from being reported as
+ something it is not.
+ +/
+ private string _reservedNameClaimedBy(O)(O obj) {
+ import std.conv : to;
+ string _anchor = obj.tags.anchor_tag_html;
+ if (_anchor.length == 0) { return ""; }
+ string _suffix = "-" ~ obj.metainfo.ocn.to!string;
+ if (_anchor.length <= _suffix.length) { return ""; }
+ if (_anchor[($ - _suffix.length) .. $] != _suffix) { return ""; }
+ string _stem = _anchor[0 .. ($ - _suffix.length)];
+ foreach (_name; reservedHeadingAnchorNames()) {
+ if (_stem == _name) { return _name; }
+ }
+ return "";
+ }
+ /+ ↓ every heading of a document that claimed a reserved name.
+ document order, the section order the .ssp is written in, so that two
+ runs over the same document report it the same way round.
+ +/
+ ST_ReservedNameUse[] reservedNameHeadings(D)(D doc) {
+ import std.algorithm : canFind, sort;
+ import std.conv : to;
+ ST_ReservedNameUse[] _out;
+ string[] _sections = ["head", "toc", "body", "endnotes",
+ "glossary", "bibliography", "bookindex", "blurb", "tail"];
+ string[] _extra;
+ foreach (_k; doc.abstraction.byKey) {
+ if (!_sections.canFind(_k)) { _extra ~= _k; }
+ }
+ foreach (_k; _extra.sort) { _sections ~= _k; }
+ foreach (section; _sections) {
+ if (section !in doc.abstraction) { continue; }
+ foreach (obj; doc.abstraction[section]) {
+ if (obj.metainfo.is_a != "heading") { continue; }
+ string _claimed = _reservedNameClaimedBy(obj);
+ if (_claimed.length == 0) { continue; }
+ /+ ↓ the generated sections carry the reserved names themselves and are
+ not what this is about: they are headings spine made, and they keep
+ the plain name. Only an object that was renamed reaches here, and a
+ generated one never is.
+ +/
+ ST_ReservedNameUse _u;
+ _u.section = section;
+ _u.reserved = _claimed;
+ _u.anchor = obj.tags.anchor_tag_html;
+ _u.level = obj.metainfo.marked_up_level;
+ _u.ocn = obj.metainfo.ocn.to!int;
+ _out ~= _u;
+ }
+ }
+ return _out;
+ }
+ /+ ↓ the report, as the lines to print.
+ the document is named once and its headings indented under it, as the ocn
+ alignment check does, so that a run over a collection reads as a list of
+ documents.
+ +/
+ string[] reservedNameReportLines(
+ string doc_key,
+ string lang,
+ ST_ReservedNameUse[] _uses,
+ ) {
+ import std.conv : to;
+ string[] _out;
+ if (_uses.length == 0) { return _out; }
+ _out ~= "WARNING reserved segment name: " ~ doc_key
+ ~ ((lang.length > 0) ? " [" ~ lang ~ "]" : "");
+ foreach (_u; _uses) {
+ _out ~= " heading " ~ _u.level ~ " at ocn " ~ _u.ocn.to!string
+ ~ " asks for \"" ~ _u.reserved
+ ~ "\", which spine gives its own generated section";
+ _out ~= " it is named \"" ~ _u.anchor
+ ~ "\" instead; give the heading another name to keep one of your own";
+ }
+ return _out;
+ }
+}
+#+END_SRC
+
+* org includes
+** project version
+
+#+NAME: spine_version
+#+HEADER: :noweb yes
+#+BEGIN_SRC emacs-lisp
+<<./sisudoc_spine_version_info_and_doc_header_including_copyright_and_license.org:spine_project_version()>>
+#+END_SRC
+
+** year
+
+#+NAME: year
+#+HEADER: :noweb yes
+#+BEGIN_SRC emacs-lisp
+<<./sisudoc_spine_version_info_and_doc_header_including_copyright_and_license.org:year()>>
+#+END_SRC
+
+** document header including copyright & license
+
+#+NAME: doc_header_including_copyright_and_license
+#+HEADER: :noweb yes
+#+BEGIN_SRC emacs-lisp
+<<./sisudoc_spine_version_info_and_doc_header_including_copyright_and_license.org:spine_doc_header_including_copyright_and_license()>>
+#+END_SRC
+
+* __END__
+
diff --git a/org/spine.org b/org/spine.org
index 49ef331..a0f0570 100644
--- a/org/spine.org
+++ b/org/spine.org
@@ -75,6 +75,12 @@ string program_name = "spine";
+/
mixin spineOcnAlign _ocna;
_ocna.ST_OcnProfile[] _ocn_profiles;
+ /+ ↓ headings that claimed a name spine keeps for a generated section.
+ Reported as each document goes by, where the document can be named, and
+ remembered here only so that --strict has something to fail on.
+ +/
+ mixin spineReservedNames _rsvd;
+ bool _reserved_name_used = false;
/+ ↓ what verify found, gathered as the documents go by +/
size_t _verify_checked = 0;
size_t _verify_failed = 0;
@@ -294,6 +300,19 @@ string program_name = "spine";
import core.stdc.stdlib : exit;
exit(1);
}
+ /+ ↓ --strict and a heading that claimed a name spine keeps for itself.
+ The same shape as the check above: the warning has already named the
+ document and the heading, the output of the run is complete and can be
+ looked at, and this is the exit status. A publishable run should not
+ contain a heading called something other than what its author wrote.
+ +/
+ if (_reserved_name_used && _opt_action.strict) {
+ stderr.writeln(
+ "~ run FAILED ~ --strict: a heading claims a reserved segment name"
+ );
+ import core.stdc.stdlib : exit;
+ exit(1);
+ }
/+ ↓ a verification that failed is a failed run, whatever else went well.
--no-verify has already said its piece per document and does not reach
here: it turns the failure into a warning and the output is produced
@@ -348,6 +367,7 @@ import sisudoc.ocda.meta.conf_make_meta_json;
import sisudoc.ocda.meta.defaults;
import sisudoc.ocda.meta.doc_debugs;
import sisudoc.ocda.meta.ocn_align;
+import sisudoc.ocda.meta.reserved_names;
import sisudoc.ocda.meta.rgx;
import sisudoc.ocda.meta.rgx_yaml;
import sisudoc.ocda.meta.rgx_files;
@@ -830,8 +850,11 @@ Verifying:
check is otherwise automatic and runs before
anything is written.
- --strict document checks that warn become failures, ocn
- alignment between languages among them.
+ --strict document checks that warn become failures: ocn
+ alignment between languages, and a heading that
+ claims a segment name spine keeps for a section of
+ its own (toc, endnotes, glossary, bibliography,
+ bookindex, blurb).
┃");
/+ ↓ --help is a question answered on stdout, not a run: the
scope(success) banner would be the last line of the answer
@@ -2384,6 +2407,23 @@ if (doc.matters.opt.action.ocda_db) {
}
synchronized { _ocn_profiles ~= _ocn_p; }
}
+/+ ↓ a heading of this document that asked for a name spine uses for a section
+ of its own. Said here, where the document can be named, and per language:
+ the markup is per language and so is the answer.
++/
+{
+ auto _rsvd_uses = _rsvd.reservedNameHeadings(doc);
+ if (_rsvd_uses.length > 0) {
+ foreach (_line; _rsvd.reservedNameReportLines(
+ doc.matters.src.doc_uid_out_no_lang,
+ doc.matters.src.language,
+ _rsvd_uses,
+ )) {
+ stderr.writeln(_line);
+ }
+ synchronized { _reserved_name_used = true; }
+ }
+}
#+END_SRC
***** align ocn non-synchronized (serial processing)
@@ -2401,6 +2441,20 @@ if (doc.matters.opt.action.ocda_db) {
}
_ocn_profiles ~= _ocn_p;
}
+/+ ↓ reserved segment names, as above +/
+{
+ auto _rsvd_uses = _rsvd.reservedNameHeadings(doc);
+ if (_rsvd_uses.length > 0) {
+ foreach (_line; _rsvd.reservedNameReportLines(
+ doc.matters.src.doc_uid_out_no_lang,
+ doc.matters.src.language,
+ _rsvd_uses,
+ )) {
+ stderr.writeln(_line);
+ }
+ _reserved_name_used = true;
+ }
+}
#+END_SRC
***** abstraction curate :abstraction:curate:
diff --git a/org/tests_for_document_abstraction_shell_scripts.org b/org/tests_for_document_abstraction_shell_scripts.org
index 41ce11e..d9cccf6 100644
--- a/org/tests_for_document_abstraction_shell_scripts.org
+++ b/org/tests_for_document_abstraction_shell_scripts.org
@@ -46,12 +46,13 @@
# Order, and what depends on what:
#
# 1 test-abstraction-ssp.sh the abstraction has not changed
-# 2 test-abstraction-ssp-roundtrip.sh the .ssp can be read back whole
-# 3 test-abstraction-db.sh the two serialisations agree
-# 4 test-abstraction-db-roundtrip.sh the .db can be read back whole
-# 5 test-ocn-alignment.sh the languages agree on their ocns
-# 6 test-acceptance-db-to-pod.sh a database gives its document back
-# 7 test-epub-validity.sh the epubs a reader can actually open
+# 2 test-run-order.sh nor does it depend on the run
+# 3 test-abstraction-ssp-roundtrip.sh the .ssp can be read back whole
+# 4 test-abstraction-db.sh the two serialisations agree
+# 5 test-abstraction-db-roundtrip.sh the .db can be read back whole
+# 6 test-ocn-alignment.sh the languages agree on their ocns
+# 7 test-acceptance-db-to-pod.sh a database gives its document back
+# 8 test-epub-validity.sh the epubs a reader can actually open
#
# None of them calls another, and each can be run on its own. The order
# matters only for reading the result:
@@ -59,21 +60,26 @@
# - 1 is first because it is the one that says whether the abstraction
# itself moved. If it fails, the others are answering a different
# question than you think, and its failure is the one to look at.
-# - 2 reads the committed reference set, so it is only a statement about
+# - 2 says that a document abstracted alone is the document abstracted in
+# company. 1 only ever runs the whole sample set, so an abstraction
+# that depended on what else was in the run passed it; this is the
+# check that would have caught that, and the one that says an artefact
+# can be verified on its own.
+# - 3 reads the committed reference set, so it is only a statement about
# the current binary if 1 passes. Run together, they say: the abstraction
# is unchanged, and this binary can read that abstraction back.
-# - 3 and 4 generate both artefacts themselves and depend on nothing
+# - 4 and 5 generate both artefacts themselves and depend on nothing
# committed. Since the database is built from the .ssp, they are checks
# on the reader rather than on two independent writers.
-# - 5 is about the sample documents rather than about a serialisation: it
+# - 6 is about the sample documents rather than about a serialisation: it
# asserts that spine reports the two known divergences in live-manual's
# translations and reports nothing about the other documents. It will
# fail if those translations are mended, which is the right moment to
# hear about it.
-# - 6 is the acceptance test for the document source claim, and the widest
+# - 7 is the acceptance test for the document source claim, and the widest
# of them: it builds a document from its pod and again from the database
# that build produced, and requires the two output trees to be identical.
-# Where 3 and 4 check one serialisation against another, this checks a
+# Where 4 and 5 check one serialisation against another, this checks a
# whole document through the whole pipeline twice. If it fails and the
# others pass, what broke is the carrying or the materialising rather
# than the abstraction.
@@ -114,6 +120,7 @@ if [ -n "$_missing" ]; then
fi
TESTS="test-abstraction-ssp.sh
+test-run-order.sh
test-abstraction-ssp-roundtrip.sh
test-abstraction-db.sh
test-abstraction-db-roundtrip.sh
@@ -170,7 +177,7 @@ else
fi
#+END_SRC
-** 1&2. ssp ocda abstraction
+** 1-3. ssp ocda abstraction
*** 1. document.ssp abstraction test/test-abstraction-ssp.sh
#+HEADER: :tangle ../test/test-abstraction-ssp.sh
@@ -362,7 +369,156 @@ else
fi
#+END_SRC
-*** 2. document.ssp abstraction test/test-abstraction-ssp-roundtrip.sh
+*** 2. document.ssp abstraction test/test-run-order.sh
+
+#+HEADER: :tangle ../test/test-run-order.sh
+#+HEADER: :tangle-mode (identity #o755)
+#+HEADER: :shebang "#!/usr/bin/env sh"
+#+BEGIN_SRC shell
+# test-run-order.sh
+#
+# A document's abstraction must not depend on what else is in the run.
+#
+# It did. The set of heading anchor names spine has seen is seeded with the
+# names it keeps for its own generated sections (toc, endnotes, glossary,
+# bibliography, bookindex, blurb, _the_title), and the seeding was written
+# only in scope(exit) and at the tail of docAbstraction(): both run *after* a
+# document, so they seeded the next one. The first document of a run began
+# with the set empty and was the one document not held to the reserved names,
+# and a heading of its own claiming "endnotes" kept the name of the generated
+# section rather than being renamed out of its way.
+#
+# So the same document abstracted alone and abstracted after another differed,
+# and an artefact written in a collection run could not be verified on its
+# own: --ocda-verify re-parses one document, where it is necessarily first,
+# and reported a correct artefact as one whose markup no longer makes it.
+#
+# The check: abstract two documents in one run, abstract each of them alone,
+# and require the .ssp to be identical either way. The second document is the
+# one that matters (it is seeded in the pair and not when alone), so the pair
+# is ordered with the affected document last.
+#
+# $SpineRunOrderDocs overrides the pair, space separated, unqualified
+# directory names within $SpinePOD. The default pair is chosen so that the
+# test would fail before the fix: the_autonomous_contract has a "1~endnotes"
+# heading, which is what the seeding renames.
+#
+# Usage:
+# SpinePOD=../../markup/sisudoc-spine-samples/markup/pod-samples/pod \
+# ./test/test-run-order.sh ./bin/spine-ldc
+#
+# Exit codes:
+# 0 a document abstracts the same alone as in company
+# 1 it does not (the differing files are printed)
+# 2 set-up problem (no $SpinePOD, no binary, no samples)
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+SPINE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+if [ -z "$SpinePOD" ]; then
+ echo "ERROR: \$SpinePOD is not set." >&2
+ echo " e.g. SpinePOD=../../markup/sisudoc-spine-samples/markup/pod-samples/pod" >&2
+ exit 2
+fi
+SAMPLES_RAW="$SPINE_DIR/$SpinePOD"
+if [ -d "$SAMPLES_RAW" ]; then
+ SAMPLES_DIR="$(cd "$SAMPLES_RAW" && pwd)"
+else
+ SAMPLES_DIR="$SAMPLES_RAW"
+fi
+if [ ! -d "$SAMPLES_DIR" ]; then
+ echo "ERROR: sample documents not found at $SAMPLES_DIR" >&2
+ exit 2
+fi
+
+SPINE_BIN="${1:-}"
+if [ -z "$SPINE_BIN" ]; then
+ if [ -x "$SPINE_DIR/result/bin/spine" ]; then SPINE_BIN="$SPINE_DIR/result/bin/spine"
+ elif [ -x "$SPINE_DIR/bin/spine-ldc" ]; then SPINE_BIN="$SPINE_DIR/bin/spine-ldc"
+ elif [ -x "$SPINE_DIR/bin/spine" ]; then SPINE_BIN="$SPINE_DIR/bin/spine"
+ else
+ echo "ERROR: spine binary not found. Specify path as argument." >&2
+ exit 2
+ fi
+fi
+
+DOCS="${SpineRunOrderDocs:-gpl2.fsf the_autonomous_contract.ralph_amissah}"
+
+for d in $DOCS; do
+ if [ ! -d "$SAMPLES_DIR/$d" ]; then
+ echo "ERROR: sample document not found: $SAMPLES_DIR/$d" >&2
+ echo " set \$SpineRunOrderDocs to a pair that is present" >&2
+ exit 2
+ fi
+done
+
+OUT_DIR="$SCRIPT_DIR/current-run-order"
+rm -rf "$OUT_DIR"
+mkdir -p "$OUT_DIR"
+
+echo "spine binary: $SPINE_BIN"
+echo "samples: $SAMPLES_DIR"
+echo "documents: $DOCS"
+
+FAILURES=0
+fail() { echo " FAIL: $*"; FAILURES=$((FAILURES + 1)); }
+
+# the pair, in one run
+_pair_args=""
+for d in $DOCS; do
+ _pair_args="$_pair_args $SAMPLES_DIR/$d"
+done
+# shellcheck disable=SC2086
+$SPINE_BIN --show-abstraction --skip-output \
+ --output="$OUT_DIR/together" $_pair_args > "$OUT_DIR/together.out" 2>&1
+
+# and each of them on its own
+for d in $DOCS; do
+ $SPINE_BIN --show-abstraction --skip-output \
+ --output="$OUT_DIR/alone-$d" "$SAMPLES_DIR/$d" \
+ > "$OUT_DIR/alone-$d.out" 2>&1
+done
+
+# every .ssp the pair run produced has to have a twin, identical, in the run
+# that produced it alone
+_compared=0
+for f in $(find "$OUT_DIR/together" -name "*.ssp" | sort); do
+ _rel="${f#"$OUT_DIR/together/"}"
+ _alone=""
+ for d in $DOCS; do
+ if [ -f "$OUT_DIR/alone-$d/$_rel" ]; then _alone="$OUT_DIR/alone-$d/$_rel"; fi
+ done
+ if [ -z "$_alone" ]; then
+ fail "$_rel was produced in the pair run and by neither document alone"
+ continue
+ fi
+ _compared=$((_compared + 1))
+ if ! cmp -s "$f" "$_alone"; then
+ fail "$_rel differs between the pair run and the run of its own document"
+ diff --unified=2 "$_alone" "$f" | head -20 | sed 's/^/ /'
+ fi
+done
+
+if [ "$_compared" -eq 0 ]; then
+ echo "ERROR: no .ssp files were produced to compare" >&2
+ rm -rf "$OUT_DIR"
+ exit 2
+fi
+
+rm -rf "$OUT_DIR"
+
+if [ "$FAILURES" -eq 0 ]; then
+ echo "PASS: $_compared .ssp identical whether the document ran alone or in company"
+ exit 0
+else
+ echo "FAIL: $FAILURES check(s)"
+ exit 1
+fi
+#+END_SRC
+
+*** 3. document.ssp abstraction test/test-abstraction-ssp-roundtrip.sh
#+HEADER: :tangle ../test/test-abstraction-ssp-roundtrip.sh
#+HEADER: :tangle-mode (identity #o755)
@@ -452,8 +608,8 @@ else
fi
#+END_SRC
-** 3&4. db ocda abstraction
-*** 3. document db ocda test/test-abstraction-db.sh
+** 4&5. db ocda abstraction
+*** 4. document db ocda test/test-abstraction-db.sh
#+HEADER: :tangle ../test/test-abstraction-db.sh
#+HEADER: :tangle-mode (identity #o755)
@@ -811,7 +967,7 @@ else
fi
#+END_SRC
*
-*** 4. document db ocda test-abstraction-db-roundtrip.sh
+*** 5. document db ocda test-abstraction-db-roundtrip.sh
#+HEADER: :tangle ../test/test-abstraction-db-roundtrip.sh
#+HEADER: :tangle-mode (identity #o755)
@@ -953,7 +1109,7 @@ else
fi
#+END_SRC
-** 5. test ocn alignment
+** 6. test ocn alignment
#+HEADER: :tangle ../test/test-ocn-alignment.sh
#+HEADER: :tangle-mode (identity #o755)
@@ -1152,7 +1308,7 @@ else
fi
#+END_SRC
-** 6. test acceptance db to pod
+** 7. test acceptance db to pod
#+HEADER: :tangle ../test/test-acceptance-db-to-pod.sh
#+HEADER: :tangle-mode (identity #o755)
@@ -1331,7 +1487,7 @@ else
fi
#+END_SRC
-** 7. test epub validity
+** 8. test epub validity
#+HEADER: :tangle ../test/test-epub-validity.sh
#+HEADER: :tangle-mode (identity #o755)
diff --git a/src/sisudoc/ocda/meta/metadoc_from_src.d b/src/sisudoc/ocda/meta/metadoc_from_src.d
index 2731573..3670a39 100644
--- a/src/sisudoc/ocda/meta/metadoc_from_src.d
+++ b/src/sisudoc/ocda/meta/metadoc_from_src.d
@@ -97,6 +97,18 @@ template docAbstraction() {
anchor_tag = "";
_heading_anchor_tags_seen = _reserved_heading_anchor_tags();
}
+ /+ ↓ seeded here, on the way in, which is the seeding that matters.
+ .
+ The two assignments in scope(exit) and at the tail of this function run
+ after a document has been abstracted, so they seed the *next* one. The
+ first document of a run had nothing before it and began with the set as
+ declared, empty: it alone was not held to the reserved names, and a heading
+ of its own claiming "endnotes" kept the name of the generated section.
+ Which document that was depended on what else was in the run, so a
+ document's abstraction depended on its company, and a single document
+ re-parsed to check an artefact made in a collection run disagreed with it.
+ +/
+ _heading_anchor_tags_seen = _reserved_heading_anchor_tags();
mixin spineNode;
int[string] node_para_int_ = node_metadata_para_int;
string[string] node_para_str_ = node_metadata_para_str;
diff --git a/src/sisudoc/ocda/meta/metadoc_from_src_functions.d b/src/sisudoc/ocda/meta/metadoc_from_src_functions.d
index 0deeb72..c5a63cd 100644
--- a/src/sisudoc/ocda/meta/metadoc_from_src_functions.d
+++ b/src/sisudoc/ocda/meta/metadoc_from_src_functions.d
@@ -77,16 +77,15 @@ template docAbstractionFunctions() {
these names means the document's heading is disambiguated the same
way a repeated anchor tag is, and the generated section keeps the
name that the table of contents links to.
+ .
+ The list itself is in sisudoc.ocda.meta.reserved_names, with the
+ check that reports such a heading to its author. Two lists would be
+ two answers to what spine has taken.
+/
bool[string] _reserved_heading_anchor_tags() {
- bool[string] _reserved;
- foreach (_name; [
- "toc", "endnotes", "glossary", "bibliography", "bookindex", "blurb",
- "_the_title",
- ]) {
- _reserved[_name] = true;
- }
- return _reserved;
+ import sisudoc.ocda.meta.reserved_names;
+ mixin spineReservedNames _rn;
+ return _rn.reservedHeadingAnchorTags();
}
string lev_anchor_tag;
string[string][string] tag_assoc;
diff --git a/src/sisudoc/ocda/meta/reserved_names.d b/src/sisudoc/ocda/meta/reserved_names.d
new file mode 100644
index 0000000..8e49ae5
--- /dev/null
+++ b/src/sisudoc/ocda/meta/reserved_names.d
@@ -0,0 +1,206 @@
+/+
+- Name: SisuDoc Spine, Doc Reform [a part of]
+ - Description: documents, structuring, processing, publishing, search
+ - static content generator
+
+ - Author: Ralph Amissah
+ [ralph.amissah@gmail.com]
+
+ - Copyright: (C) 2015 (continuously updated, current 2026) Ralph Amissah, All Rights Reserved.
+
+ - License: AGPL 3 or later:
+
+ Spine (SiSU), a framework for document structuring, publishing and
+ search
+
+ Copyright (C) Ralph Amissah
+
+ This program is free software: you can redistribute it and/or modify it
+ under the terms of the GNU AFERO General Public License as published by the
+ Free Software Foundation, either version 3 of the License, or (at your
+ option) any later version.
+
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ more details.
+
+ You should have received a copy of the GNU General Public License along with
+ this program. If not, see [https://www.gnu.org/licenses/].
+
+ If you have Internet connection, the latest version of the AGPL should be
+ available at these locations:
+ [https://www.fsf.org/licensing/licenses/agpl.html]
+ [https://www.gnu.org/licenses/agpl.html]
+
+ - Spine (by Doc Reform, related to SiSU) uses standard:
+ - docReform markup syntax
+ - standard SiSU markup syntax with modified headers and minor modifications
+ - docReform object numbering
+ - standard SiSU object citation numbering & system
+
+ - Homepages:
+ [https://www.sisudoc.org]
+ [https://www.doc-reform.org]
+
+ - Git
+ [https://git.sisudoc.org/]
+
++/
+/++
+ the segment names spine keeps for itself<br><br>
+
+ the one list of them, and the check that says when a document's own
+ heading has claimed one<br><br>
+
+ [sisudoc.ocda.meta.reserved_names]
++/
+module sisudoc.ocda.meta.reserved_names;
+@safe:
+/+ ↓ spine names the sections it generates, these names are reserved.
+ .
+ The table of contents, the gathered endnotes, the glossary, the
+ bibliography, the book index and the blurb are not in the markup: spine
+ makes them, and gives each a segment name of its own. A document whose
+ own heading asks for one of those names ("1~endnotes Endnote") is asking
+ for a name that is already taken, and before this was noticed both wrote
+ into the same file: the generated section and the author's heading, with
+ the navigation listing that file twice, the second time behind its own
+ earlier entry in reading order.
+ .
+ The parser therefore seeds the set of heading anchor names it has seen
+ with these before it reads a document, so that a heading claiming one is
+ disambiguated by ocn suffix the same way a repeated anchor tag is, and
+ the generated section keeps the name the table of contents links to.
+ .
+ Two things live here rather than in the parser:
+ .
+ the list so that the parser's seeding and the check below cannot
+ come to hold different ideas of what is reserved;
+ .
+ the check which runs over a finished abstraction, names the
+ headings that were renamed, and is what tells an author
+ that a heading of theirs is not called what they wrote.
+ A warning: the document is correct and complete, and the
+ name it ends up with works. --strict makes it a failure,
+ for the run that is meant to be publishable.
+ .
+ The renaming is not something the author has to accept. A heading given
+ any other anchor name ("1~my-notes Endnote") keeps it, and the warning
+ goes away.
++/
+/+ ↓ mixed in as a named mixin (mixin spineReservedNames _name;) and its
+ imports are inside its functions rather than at template scope: what a
+ mixin template declares at its own scope is visible in the scope it is
+ mixed into, and an import there can hijack a name the host was already
+ resolving by UFCS.
++/
+template spineReservedNames() {
+ /+ ↓ the names spine makes for itself, in one place.
+ _the_title is the head section's own; the rest are the generated
+ sections, and are the section keys of the abstraction.
+ +/
+ string[] reservedHeadingAnchorNames() {
+ return [
+ "toc", "endnotes", "glossary", "bibliography", "bookindex", "blurb",
+ "_the_title",
+ ];
+ }
+ /+ ↓ the same, as the set the parser seeds itself with +/
+ bool[string] reservedHeadingAnchorTags() {
+ bool[string] _reserved;
+ foreach (_name; reservedHeadingAnchorNames()) { _reserved[_name] = true; }
+ return _reserved;
+ }
+ /+ ↓ one heading that asked for a name spine had already taken +/
+ struct ST_ReservedNameUse {
+ string section; // where in the abstraction it sits
+ string reserved; // the name it asked for
+ string anchor; // the name it was given instead
+ string level; // as marked up, :A :B 1 2 ...
+ int ocn;
+ }
+ /+ ↓ was this heading renamed out of the way of a reserved name?
+ .
+ Read off the anchor rather than reported by the parser: the parser
+ renames by appending the object's number, so "endnotes" becomes
+ "endnotes-135" on the object whose ocn is 135, and that pairing is what
+ identifies it. Requiring the number to be the object's own is what keeps
+ a heading an author really did call "endnotes-135" from being reported as
+ something it is not.
+ +/
+ private string _reservedNameClaimedBy(O)(O obj) {
+ import std.conv : to;
+ string _anchor = obj.tags.anchor_tag_html;
+ if (_anchor.length == 0) { return ""; }
+ string _suffix = "-" ~ obj.metainfo.ocn.to!string;
+ if (_anchor.length <= _suffix.length) { return ""; }
+ if (_anchor[($ - _suffix.length) .. $] != _suffix) { return ""; }
+ string _stem = _anchor[0 .. ($ - _suffix.length)];
+ foreach (_name; reservedHeadingAnchorNames()) {
+ if (_stem == _name) { return _name; }
+ }
+ return "";
+ }
+ /+ ↓ every heading of a document that claimed a reserved name.
+ document order, the section order the .ssp is written in, so that two
+ runs over the same document report it the same way round.
+ +/
+ ST_ReservedNameUse[] reservedNameHeadings(D)(D doc) {
+ import std.algorithm : canFind, sort;
+ import std.conv : to;
+ ST_ReservedNameUse[] _out;
+ string[] _sections = ["head", "toc", "body", "endnotes",
+ "glossary", "bibliography", "bookindex", "blurb", "tail"];
+ string[] _extra;
+ foreach (_k; doc.abstraction.byKey) {
+ if (!_sections.canFind(_k)) { _extra ~= _k; }
+ }
+ foreach (_k; _extra.sort) { _sections ~= _k; }
+ foreach (section; _sections) {
+ if (section !in doc.abstraction) { continue; }
+ foreach (obj; doc.abstraction[section]) {
+ if (obj.metainfo.is_a != "heading") { continue; }
+ string _claimed = _reservedNameClaimedBy(obj);
+ if (_claimed.length == 0) { continue; }
+ /+ ↓ the generated sections carry the reserved names themselves and are
+ not what this is about: they are headings spine made, and they keep
+ the plain name. Only an object that was renamed reaches here, and a
+ generated one never is.
+ +/
+ ST_ReservedNameUse _u;
+ _u.section = section;
+ _u.reserved = _claimed;
+ _u.anchor = obj.tags.anchor_tag_html;
+ _u.level = obj.metainfo.marked_up_level;
+ _u.ocn = obj.metainfo.ocn.to!int;
+ _out ~= _u;
+ }
+ }
+ return _out;
+ }
+ /+ ↓ the report, as the lines to print.
+ the document is named once and its headings indented under it, as the ocn
+ alignment check does, so that a run over a collection reads as a list of
+ documents.
+ +/
+ string[] reservedNameReportLines(
+ string doc_key,
+ string lang,
+ ST_ReservedNameUse[] _uses,
+ ) {
+ import std.conv : to;
+ string[] _out;
+ if (_uses.length == 0) { return _out; }
+ _out ~= "WARNING reserved segment name: " ~ doc_key
+ ~ ((lang.length > 0) ? " [" ~ lang ~ "]" : "");
+ foreach (_u; _uses) {
+ _out ~= " heading " ~ _u.level ~ " at ocn " ~ _u.ocn.to!string
+ ~ " asks for \"" ~ _u.reserved
+ ~ "\", which spine gives its own generated section";
+ _out ~= " it is named \"" ~ _u.anchor
+ ~ "\" instead; give the heading another name to keep one of your own";
+ }
+ return _out;
+ }
+}
diff --git a/src/sisudoc/spine.d b/src/sisudoc/spine.d
index 18beda2..c1a3f63 100644
--- a/src/sisudoc/spine.d
+++ b/src/sisudoc/spine.d
@@ -72,6 +72,7 @@ import sisudoc.ocda.meta.conf_make_meta_json;
import sisudoc.ocda.meta.defaults;
import sisudoc.ocda.meta.doc_debugs;
import sisudoc.ocda.meta.ocn_align;
+import sisudoc.ocda.meta.reserved_names;
import sisudoc.ocda.meta.rgx;
import sisudoc.ocda.meta.rgx_yaml;
import sisudoc.ocda.meta.rgx_files;
@@ -438,8 +439,11 @@ Verifying:
check is otherwise automatic and runs before
anything is written.
- --strict document checks that warn become failures, ocn
- alignment between languages among them.
+ --strict document checks that warn become failures: ocn
+ alignment between languages, and a heading that
+ claims a segment name spine keeps for a section of
+ its own (toc, endnotes, glossary, bibliography,
+ bookindex, blurb).
┃");
/+ ↓ --help is a question answered on stdout, not a run: the
scope(success) banner would be the last line of the answer
@@ -1681,6 +1685,12 @@ Verifying:
+/
mixin spineOcnAlign _ocna;
_ocna.ST_OcnProfile[] _ocn_profiles;
+ /+ ↓ headings that claimed a name spine keeps for a generated section.
+ Reported as each document goes by, where the document can be named, and
+ remembered here only so that --strict has something to fail on.
+ +/
+ mixin spineReservedNames _rsvd;
+ bool _reserved_name_used = false;
/+ ↓ what verify found, gathered as the documents go by +/
size_t _verify_checked = 0;
size_t _verify_failed = 0;
@@ -1854,6 +1864,23 @@ Verifying:
}
synchronized { _ocn_profiles ~= _ocn_p; }
}
+ /+ ↓ a heading of this document that asked for a name spine uses for a section
+ of its own. Said here, where the document can be named, and per language:
+ the markup is per language and so is the answer.
+ +/
+ {
+ auto _rsvd_uses = _rsvd.reservedNameHeadings(doc);
+ if (_rsvd_uses.length > 0) {
+ foreach (_line; _rsvd.reservedNameReportLines(
+ doc.matters.src.doc_uid_out_no_lang,
+ doc.matters.src.language,
+ _rsvd_uses,
+ )) {
+ stderr.writeln(_line);
+ }
+ synchronized { _reserved_name_used = true; }
+ }
+ }
if (doc.matters.opt.action.curate) {
auto _hvst = spineMetaDocCurate!()(doc.matters, hvst);
if (
@@ -2058,6 +2085,20 @@ Verifying:
}
_ocn_profiles ~= _ocn_p;
}
+ /+ ↓ reserved segment names, as above +/
+ {
+ auto _rsvd_uses = _rsvd.reservedNameHeadings(doc);
+ if (_rsvd_uses.length > 0) {
+ foreach (_line; _rsvd.reservedNameReportLines(
+ doc.matters.src.doc_uid_out_no_lang,
+ doc.matters.src.language,
+ _rsvd_uses,
+ )) {
+ stderr.writeln(_line);
+ }
+ _reserved_name_used = true;
+ }
+ }
if (doc.matters.opt.action.curate) {
auto _hvst = spineMetaDocCurate!()(doc.matters, hvst);
if (
@@ -2288,6 +2329,19 @@ Verifying:
import core.stdc.stdlib : exit;
exit(1);
}
+ /+ ↓ --strict and a heading that claimed a name spine keeps for itself.
+ The same shape as the check above: the warning has already named the
+ document and the heading, the output of the run is complete and can be
+ looked at, and this is the exit status. A publishable run should not
+ contain a heading called something other than what its author wrote.
+ +/
+ if (_reserved_name_used && _opt_action.strict) {
+ stderr.writeln(
+ "~ run FAILED ~ --strict: a heading claims a reserved segment name"
+ );
+ import core.stdc.stdlib : exit;
+ exit(1);
+ }
/+ ↓ a verification that failed is a failed run, whatever else went well.
--no-verify has already said its piece per document and does not reach
here: it turns the failure into a warning and the output is produced
diff --git a/test/run-tests.sh b/test/run-tests.sh
index d8822d4..f970194 100755
--- a/test/run-tests.sh
+++ b/test/run-tests.sh
@@ -23,12 +23,13 @@
# Order, and what depends on what:
#
# 1 test-abstraction-ssp.sh the abstraction has not changed
-# 2 test-abstraction-ssp-roundtrip.sh the .ssp can be read back whole
-# 3 test-abstraction-db.sh the two serialisations agree
-# 4 test-abstraction-db-roundtrip.sh the .db can be read back whole
-# 5 test-ocn-alignment.sh the languages agree on their ocns
-# 6 test-acceptance-db-to-pod.sh a database gives its document back
-# 7 test-epub-validity.sh the epubs a reader can actually open
+# 2 test-run-order.sh nor does it depend on the run
+# 3 test-abstraction-ssp-roundtrip.sh the .ssp can be read back whole
+# 4 test-abstraction-db.sh the two serialisations agree
+# 5 test-abstraction-db-roundtrip.sh the .db can be read back whole
+# 6 test-ocn-alignment.sh the languages agree on their ocns
+# 7 test-acceptance-db-to-pod.sh a database gives its document back
+# 8 test-epub-validity.sh the epubs a reader can actually open
#
# None of them calls another, and each can be run on its own. The order
# matters only for reading the result:
@@ -36,21 +37,26 @@
# - 1 is first because it is the one that says whether the abstraction
# itself moved. If it fails, the others are answering a different
# question than you think, and its failure is the one to look at.
-# - 2 reads the committed reference set, so it is only a statement about
+# - 2 says that a document abstracted alone is the document abstracted in
+# company. 1 only ever runs the whole sample set, so an abstraction
+# that depended on what else was in the run passed it; this is the
+# check that would have caught that, and the one that says an artefact
+# can be verified on its own.
+# - 3 reads the committed reference set, so it is only a statement about
# the current binary if 1 passes. Run together, they say: the abstraction
# is unchanged, and this binary can read that abstraction back.
-# - 3 and 4 generate both artefacts themselves and depend on nothing
+# - 4 and 5 generate both artefacts themselves and depend on nothing
# committed. Since the database is built from the .ssp, they are checks
# on the reader rather than on two independent writers.
-# - 5 is about the sample documents rather than about a serialisation: it
+# - 6 is about the sample documents rather than about a serialisation: it
# asserts that spine reports the two known divergences in live-manual's
# translations and reports nothing about the other documents. It will
# fail if those translations are mended, which is the right moment to
# hear about it.
-# - 6 is the acceptance test for the document source claim, and the widest
+# - 7 is the acceptance test for the document source claim, and the widest
# of them: it builds a document from its pod and again from the database
# that build produced, and requires the two output trees to be identical.
-# Where 3 and 4 check one serialisation against another, this checks a
+# Where 4 and 5 check one serialisation against another, this checks a
# whole document through the whole pipeline twice. If it fails and the
# others pass, what broke is the carrying or the materialising rather
# than the abstraction.
@@ -91,6 +97,7 @@ if [ -n "$_missing" ]; then
fi
TESTS="test-abstraction-ssp.sh
+test-run-order.sh
test-abstraction-ssp-roundtrip.sh
test-abstraction-db.sh
test-abstraction-db-roundtrip.sh
diff --git a/test/test-run-order.sh b/test/test-run-order.sh
new file mode 100755
index 0000000..29c1b7b
--- /dev/null
+++ b/test/test-run-order.sh
@@ -0,0 +1,142 @@
+#!/usr/bin/env sh
+# test-run-order.sh
+#
+# A document's abstraction must not depend on what else is in the run.
+#
+# It did. The set of heading anchor names spine has seen is seeded with the
+# names it keeps for its own generated sections (toc, endnotes, glossary,
+# bibliography, bookindex, blurb, _the_title), and the seeding was written
+# only in scope(exit) and at the tail of docAbstraction(): both run *after* a
+# document, so they seeded the next one. The first document of a run began
+# with the set empty and was the one document not held to the reserved names,
+# and a heading of its own claiming "endnotes" kept the name of the generated
+# section rather than being renamed out of its way.
+#
+# So the same document abstracted alone and abstracted after another differed,
+# and an artefact written in a collection run could not be verified on its
+# own: --ocda-verify re-parses one document, where it is necessarily first,
+# and reported a correct artefact as one whose markup no longer makes it.
+#
+# The check: abstract two documents in one run, abstract each of them alone,
+# and require the .ssp to be identical either way. The second document is the
+# one that matters (it is seeded in the pair and not when alone), so the pair
+# is ordered with the affected document last.
+#
+# $SpineRunOrderDocs overrides the pair, space separated, unqualified
+# directory names within $SpinePOD. The default pair is chosen so that the
+# test would fail before the fix: the_autonomous_contract has a "1~endnotes"
+# heading, which is what the seeding renames.
+#
+# Usage:
+# SpinePOD=../../markup/sisudoc-spine-samples/markup/pod-samples/pod \
+# ./test/test-run-order.sh ./bin/spine-ldc
+#
+# Exit codes:
+# 0 a document abstracts the same alone as in company
+# 1 it does not (the differing files are printed)
+# 2 set-up problem (no $SpinePOD, no binary, no samples)
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+SPINE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+if [ -z "$SpinePOD" ]; then
+ echo "ERROR: \$SpinePOD is not set." >&2
+ echo " e.g. SpinePOD=../../markup/sisudoc-spine-samples/markup/pod-samples/pod" >&2
+ exit 2
+fi
+SAMPLES_RAW="$SPINE_DIR/$SpinePOD"
+if [ -d "$SAMPLES_RAW" ]; then
+ SAMPLES_DIR="$(cd "$SAMPLES_RAW" && pwd)"
+else
+ SAMPLES_DIR="$SAMPLES_RAW"
+fi
+if [ ! -d "$SAMPLES_DIR" ]; then
+ echo "ERROR: sample documents not found at $SAMPLES_DIR" >&2
+ exit 2
+fi
+
+SPINE_BIN="${1:-}"
+if [ -z "$SPINE_BIN" ]; then
+ if [ -x "$SPINE_DIR/result/bin/spine" ]; then SPINE_BIN="$SPINE_DIR/result/bin/spine"
+ elif [ -x "$SPINE_DIR/bin/spine-ldc" ]; then SPINE_BIN="$SPINE_DIR/bin/spine-ldc"
+ elif [ -x "$SPINE_DIR/bin/spine" ]; then SPINE_BIN="$SPINE_DIR/bin/spine"
+ else
+ echo "ERROR: spine binary not found. Specify path as argument." >&2
+ exit 2
+ fi
+fi
+
+DOCS="${SpineRunOrderDocs:-gpl2.fsf the_autonomous_contract.ralph_amissah}"
+
+for d in $DOCS; do
+ if [ ! -d "$SAMPLES_DIR/$d" ]; then
+ echo "ERROR: sample document not found: $SAMPLES_DIR/$d" >&2
+ echo " set \$SpineRunOrderDocs to a pair that is present" >&2
+ exit 2
+ fi
+done
+
+OUT_DIR="$SCRIPT_DIR/current-run-order"
+rm -rf "$OUT_DIR"
+mkdir -p "$OUT_DIR"
+
+echo "spine binary: $SPINE_BIN"
+echo "samples: $SAMPLES_DIR"
+echo "documents: $DOCS"
+
+FAILURES=0
+fail() { echo " FAIL: $*"; FAILURES=$((FAILURES + 1)); }
+
+# the pair, in one run
+_pair_args=""
+for d in $DOCS; do
+ _pair_args="$_pair_args $SAMPLES_DIR/$d"
+done
+# shellcheck disable=SC2086
+$SPINE_BIN --show-abstraction --skip-output \
+ --output="$OUT_DIR/together" $_pair_args > "$OUT_DIR/together.out" 2>&1
+
+# and each of them on its own
+for d in $DOCS; do
+ $SPINE_BIN --show-abstraction --skip-output \
+ --output="$OUT_DIR/alone-$d" "$SAMPLES_DIR/$d" \
+ > "$OUT_DIR/alone-$d.out" 2>&1
+done
+
+# every .ssp the pair run produced has to have a twin, identical, in the run
+# that produced it alone
+_compared=0
+for f in $(find "$OUT_DIR/together" -name "*.ssp" | sort); do
+ _rel="${f#"$OUT_DIR/together/"}"
+ _alone=""
+ for d in $DOCS; do
+ if [ -f "$OUT_DIR/alone-$d/$_rel" ]; then _alone="$OUT_DIR/alone-$d/$_rel"; fi
+ done
+ if [ -z "$_alone" ]; then
+ fail "$_rel was produced in the pair run and by neither document alone"
+ continue
+ fi
+ _compared=$((_compared + 1))
+ if ! cmp -s "$f" "$_alone"; then
+ fail "$_rel differs between the pair run and the run of its own document"
+ diff --unified=2 "$_alone" "$f" | head -20 | sed 's/^/ /'
+ fi
+done
+
+if [ "$_compared" -eq 0 ]; then
+ echo "ERROR: no .ssp files were produced to compare" >&2
+ rm -rf "$OUT_DIR"
+ exit 2
+fi
+
+rm -rf "$OUT_DIR"
+
+if [ "$FAILURES" -eq 0 ]; then
+ echo "PASS: $_compared .ssp identical whether the document ran alone or in company"
+ exit 0
+else
+ echo "FAIL: $FAILURES check(s)"
+ exit 1
+fi