diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..d9bdc490
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,72 @@
+# VCS
+.git
+.github/
+.gitattributes
+.gitignore
+
+# OS/editor junk
+.DS_Store
+Thumbs.db
+*.swp
+*.swo
+*~
+*.log
+.idea/
+.vscode/
+
+# Local build outputs
+bin/
+
+src/**/*.o
+src/**/*.a
+
+src/anal/cruncher
+src/anal/deverbal
+src/anal/findbase
+src/anal/morpheus
+src/anal/pname
+
+src/gener/checkstype
+src/gener/do_conj
+src/gener/gener
+
+src/gkdict/combitype
+src/gkdict/conj1
+src/gkdict/fixgend
+src/gkdict/fixhesc
+src/gkdict/indexcomps
+src/gkdict/indexnoms
+src/gkdict/indexvbs
+src/gkdict/latnom
+src/gkdict/latvb
+src/gkdict/newlems
+src/gkdict/newlems2
+src/gkdict/setquant
+src/gkdict/splitlat
+src/gkdict/splitlems
+
+src/gkends/buildderiv
+src/gkends/buildend
+src/gkends/buildword
+src/gkends/indderivtables
+src/gkends/indendtables
+
+# update.sh outputs
+stemlib/Latin/conjfile
+stemlib/Latin/oddfile
+
+stemlib/**/endtables/ascii/**
+stemlib/**/endtables/indices/**
+stemlib/**/endtables/out/**
+
+stemlib/**/derivs/ascii/
+stemlib/**/derivs/indices/
+stemlib/**/derivs/out/
+
+stemlib/**/steminds/
+
+stemlib/Latin/stemsrc/nom.irreg
+stemlib/Latin/stemsrc/vbs.irreg
+
+# Optional: repo docs not needed for the build
+doc/
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 469fce39..7fe4344a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ bin
src/anal/cruncher
src/anal/deverbal
src/anal/findbase
+src/anal/morpheus
src/anal/pname
src/gener/checkstype
src/gener/do_conj
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 00000000..80d7e22d
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,19 @@
+FROM ubuntu:24.04
+
+ARG DEBIAN_FRONTEND=noninteractive
+
+# Install dependencies for compiling and for the data generation scripts
+RUN apt-get update -qq && apt-get install -qq -y \
+ build-essential \
+ flex \
+ perl
+
+ADD . /morpheus
+WORKDIR /morpheus
+
+# fix line endings / make executable for the perl script (still needed)
+RUN chmod +x stemlib/Greek/getentities.pl && \
+ sed -i 's/\r$//' stemlib/Greek/getentities.pl
+
+RUN make && \
+ echo "salve" | MORPHLIB=stemlib bin/cruncher -L
\ No newline at end of file
diff --git a/README_DOCKER.md b/README_DOCKER.md
new file mode 100644
index 00000000..2a182b72
--- /dev/null
+++ b/README_DOCKER.md
@@ -0,0 +1,62 @@
+Docker
+======
+
+### Build the image
+
+```bash
+docker build -t [IMAGE_ID] .
+```
+[1]
+
+### Run the image
+
+You can run the tools either interactively or directly via a one-shot command.
+
+#### Option A: Interactively
+
+``` bash
+docker run -it --rm [IMAGE_ID] bash
+```
+
+Inside the container:
+
+- **cruncher** (always available):
+
+ ```bash
+ echo "rosa" | MORPHLIB=stemlib bin/cruncher -L
+ ```
+ [2]
+
+- **morpheus** XML output [3]
+
+ ```bash
+ echo "rosa" | MORPHLIB=stemlib bin/morpheus -L
+ ```
+
+#### Option B: One-shot command
+
+You can also execute a run directly from your host without entering an
+interactive shell:
+
+```bash
+docker run --rm [IMAGE_ID] /bin/sh -c 'echo "rosa" | MORPHLIB=stemlib bin/cruncher -L'
+```
+
+### Footnotes
+
+
+ -
+ IMAGE_ID stands for any name you want to give
+ ↩
+
+ -
+ "-L" selects Latin; drop it for Greek (the default). See the main
+ README for the rest of the flags
+ ↩
+
+ -
+ Only if this build includes it; check first
+ with "ls bin/morpheus" inside the container, or "docker run --rm [IMAGE_ID] ls bin/" from outside
+ ↩
+
+
\ No newline at end of file
diff --git a/README_XML.md b/README_XML.md
new file mode 100644
index 00000000..88839cbd
--- /dev/null
+++ b/README_XML.md
@@ -0,0 +1,207 @@
+morpheus XML output
+====================
+
+This branch adds `morpheus`, a second binary alongside `cruncher` that shares
+the same analysis pipeline (`AnalyzeString`, `SortAnals`, `GoodAnals`) but
+emits Perseus-style XML instead of the ``-line format. It is unconditional:
+morpheus always emits XML, there is no flag to switch it off.
+
+Why XML
+-------
+
+`cruncher`'s Perseus format represents each analysis as a single positional
+text line: abbreviated vocabulary (`nom`, `masc`, `pres`), a single
+part-of-speech prefix (`P/N/V/E/I`), and multi-value fields on one line
+(e.g. `nom/voc/acc masc/fem pl`). morpheus renders the same analyses as a
+structured document instead: full-word vocabulary, a refined part of speech
+per analysis, and multi-value fields expanded into a cartesian product of
+`` elements (one case/gender/etc. combination per element), so
+downstream code doesn't have to re-parse a slash-separated field.
+
+Because both binaries run through the same `SortAnals`/`GoodAnals` filtering,
+the *set* of analyses is guaranteed identical between the two. XML is a
+different serialization of the same data, not a different analysis.
+
+Building
+--------
+
+`morpheus` is built as part of the normal top-level build:
+```bash
+make
+```
+
+It installs to `bin/morpheus` alongside `cruncher`.
+
+Architecture / rebase isolation
+--------------------------------
+
+This is meant to be a long-lived branch that stays rebasable against
+upstream. The XML support is kept isolated to two files:
+
+- `src/anal/morpheus.c` — dedicated main loop (arg parsing, word intake).
+- `src/anal/prntalph.c` — XML formatting and vocabulary-mapping tables.
+
+The core analysis pipeline and dictionary compilation (`checkstring`,
+`AnalyzeString`, `gener/`, `gkdict/`) are untouched. If you rebase and the
+dictionary changes, `morpheus`'s output changes exactly as much as
+`cruncher`'s does. No separate mapping logic to keep in sync.
+
+Running morpheus
+-----------------
+```bash
+MORPHLIB=stemlib bin/morpheus < wordlist
+```
+
+Words can also be given directly as arguments instead of on stdin:
+
+```bash
+MORPHLIB=stemlib bin/morpheus lo/gos timh/
+```
+
+For Latin, add -L:
+
+```bash
+echo "rosa" | MORPHLIB=stemlib bin/morpheus -L
+```
+
+```bash
+MORPHLIB=stemlib bin/morpheus -L rosa dominus
+```
+
+The `echo | ...` form pipes the word in on stdin; the
+`bin/morpheus -L rosa dominus` form passes words as argv arguments instead.
+Both produce identical output for a single word — see above for how the two
+input paths differ.
+
+Each argv word is analyzed as its own word (not split further); after argv words
+are consumed, morpheus exits rather than falling through to read stdin.
+
+### Flags
+
+Shares most analysis flags with `cruncher`:
+
+`-L` Latin instead of Greek. `-I` Italian. `-S` disables strict case matching
+(allows capitalized/sentence-initial words). `-n` accent-insensitive retry.
+`-V` verb forms only. `-i` adds a `` block per analysis with
+low-level internal fields (raw stem/suffix/preverb pieces, morphflag bytes).
+
+`-c`, `-p`, `-x` are accepted (for `getopt` parity with `cruncher`'s flag set)
+but currently have no effect on morpheus's output: it always produces XML
+regardless.
+
+### Output format
+
+Root element is `...`. Comment-only lines in the input
+(starting with `#`) pass through as XML comments; literal `--` inside a
+comment is neutralized to `- ` so it can't collide with ``.
+
+Each analyzed word is a `` element containing a `\n");
+}
+
+void alpheiosDumpAnalysis(
+gk_analysis* analysis,
+FILE* fout)
+{
+ /*
+ Note: The lookup tables for gender and case may
+ return a multi-valued string (with values separated by "/")
+ so we need to iterate and produce an inflection element for
+ each combination of gender and case.
+ */
+
+ /* calculate term (stem + suffix) to display */
+ char stem[BUFSIZ];
+ char suffix[BUFSIZ];
+ char temp[BUFSIZ];
+ int stemlen = 0;
+ int suffixlen = 0;
+ *stem = '\0';
+ *suffix = '\0';
+
+ /* build stem from preverb, aug1, stem, with colons between pieces */
+ const char* part = preverb_of(analysis);
+ if (part && *part)
+ {
+ strncat(stem, part, BUFSIZ - 1);
+ stemlen = strlen(stem);
+ }
+ /* aug1's containing > seem to indicate form changes already present */
+ /* in other parts; those without represent a new piece */
+ part = aug1_of(analysis);
+ if (part && *part && !strchr(part, '>'))
+ {
+ if (stemlen > 0)
+ strncat(stem, ":", BUFSIZ - stemlen - 1);
+ strncat(stem, part, BUFSIZ - strlen(stem) - 1);
+ stemlen = strlen(stem);
+ }
+ part = stem_of(analysis);
+ if (part && *part)
+ {
+ if (stemlen > 0)
+ strncat(stem, ":", BUFSIZ - stemlen - 1);
+ strncat(stem, part, BUFSIZ - strlen(stem) - 1);
+ stemlen = strlen(stem);
+ }
+
+ /* build suffix from suffix and endstring */
+ part = suffix_of(analysis);
+ if (part && *part)
+ {
+ strncat(suffix, part, BUFSIZ - 1);
+ suffixlen = strlen(suffix);
+ }
+ part = endstring_of(analysis);
+ if (part && *part)
+ {
+ if (suffixlen > 0)
+ strncat(suffix, ":", BUFSIZ - suffixlen - 1);
+ strncat(suffix, part, BUFSIZ - strlen(suffix) - 1);
+ suffixlen = strlen(suffix);
+ }
+
+ /* get case(s), initialize ptrs to first case */
+ word_form wf = forminfo_of(analysis);
+ const char* caseNames = alpheiosMorphLookup(alpheiosCaseNames, case_of(wf));
+ const char* nextCase;
+ const char* endCase;
+ if (caseNames)
+ {
+ nextCase = caseNames;
+ endCase = strchr(nextCase, '/');
+ if (!endCase)
+ endCase = nextCase + strlen(nextCase);
+ }
+ else
+ {
+ nextCase = endCase = "";
+ }
+
+ /* for each case (using empty string if none exist) */
+ while (nextCase)
+ {
+ /* get gender(s), initialize ptrs to first gender */
+ const char* genderNames = alpheiosMorphLookup(alpheiosGenderNames,
+ gender_of(wf));
+ const char* nextGender;
+ const char* endGender;
+ if (genderNames)
+ {
+ nextGender = genderNames;
+ endGender = strchr(nextGender, '/');
+ if (!endGender)
+ endGender = nextGender + strlen(nextGender);
+ }
+ else
+ {
+ nextGender = endGender = "";
+ }
+
+ /* for each gender (using empty string if none exist) */
+ while (nextGender)
+ {
+ fprintf(fout, "\n");
+
+ /* put out term */
+ fprintf(fout, "", get_xml_lang());
+ if (stemlen > 0)
+ {
+ fprintf(fout, "");
+ xml_write_text(fout, stem);
+ fprintf(fout, "");
+ }
+ if (suffixlen > 0)
+ {
+ fprintf(fout, "");
+ xml_write_text(fout, suffix);
+ fprintf(fout, "");
+ }
+ fprintf(fout, "\n");
+
+ /* put out part of speech */
+ alpheiosDumpPartOfSpeech(analysis, fout, 0);
+
+ /* dump case and gender (if any) and other morphological info */
+ int caseLen = endCase - nextCase;
+ int genderLen = endGender - nextGender;
+ if (caseLen)
+ {
+ strncpy(temp, nextCase, caseLen);
+ temp[caseLen] = '\0';
+ const char *ord = alpheiosAttributeLookup(alpheiosCaseOrder, temp);
+ fprintf(fout, "");
+ xml_write_text(fout, temp);
+ fprintf(fout, "\n");
+ }
+ if (genderLen)
+ {
+ strncpy(temp, nextGender, genderLen);
+ temp[genderLen] = '\0';
+ fprintf(fout, "");
+ xml_write_text(fout, temp);
+ fprintf(fout, "\n");
+ }
+ alpheiosDumpMorphology(wf, fout);
+
+ /* other info: geographic region, dialect, types, etc. */
+ alpheiosDumpFlags("geo",
+ alpheiosGeoNames,
+ geogregion_of(analysis),
+ fout);
+ alpheiosDumpFlags("dial",
+ alpheiosDialectNames,
+ dialect_of(analysis),
+ fout);
+ const char* val = NameOfStemtype(stemtype_of(analysis));
+ if (val && *val)
+ {
+ fprintf(fout, "");
+ xml_write_text(fout, val);
+ fprintf(fout, "\n");
+ }
+
+ val = NameOfDerivtype(derivtype_of(analysis));
+ if (val && *val)
+ {
+ fprintf(fout, "");
+ xml_write_text(fout, val);
+ fprintf(fout, "\n");
+ }
+
+ *temp = '\0';
+ MorphNames(morphflags_of(analysis), temp, " ", 1);
+ if (*temp)
+ {
+ fprintf(fout, "");
+ xml_write_text(fout, temp);
+ fprintf(fout, "\n");
+ }
+
+ fprintf(fout, "\n");
+
+ /* advance to next gender */
+ if (*endGender == '/')
+ {
+ nextGender = endGender + 1;
+ endGender = strchr(nextGender, '/');
+ if (!endGender)
+ endGender = nextGender + strlen(nextGender);
+ }
+ else
+ {
+ nextGender = NULL;
+ }
+ }
+
+ /* advance to next case */
+ if (*endCase == '/')
+ {
+ nextCase = endCase + 1;
+ endCase = strchr(nextCase, '/');
+ if (!endCase)
+ endCase = nextCase + strlen(nextCase);
+ }
+ else
+ {
+ nextCase = NULL;
+ }
+ }
+}
+
+/* dump part of speech */
+const char* alpheiosDumpPartOfSpeech(
+gk_analysis* analysis,
+FILE* fout,
+int nopart)
+{
+ /* check various part of speech forms */
+ const char* pofs = NULL;
+ if (Is_participle(analysis))
+ {
+ /* if not looking for participles, say it's a verb */
+ pofs = (nopart ? "verb" : "verb participle");
+ }
+ else if (Is_nounform(analysis))
+ {
+ pofs = "noun";
+ }
+ else if (Is_adjform(analysis))
+ {
+ pofs = "adjective";
+ }
+ else if (Is_verbform(analysis))
+ {
+ pofs = "verb";
+ }
+
+ /* check stemtype and adjust part of speech */
+ const char* stemType = NameOfStemtype(stemtype_of(analysis));
+ if (stemType && *stemType)
+ {
+ if (strstr(stemType, "pron") ||
+ !strcmp(stemType, "indef") ||
+ !strcmp(stemType, "relative") ||
+ !strcmp(stemType, "demonstr") ||
+ !strcmp(stemType, "art_adj"))
+ pofs = "pronoun";
+ else if (strstr(stemType, "_adj"))
+ pofs = "adjective";
+ else if (!strcmp(stemType, "adverb") ||
+ !strcmp(stemType, "article") ||
+ !strcmp(stemType, "particle") ||
+ !strcmp(stemType, "numeral"))
+ pofs = stemType;
+ else if (!strcmp(stemType, "conj"))
+ pofs = "conjunction";
+ else if (!strcmp(stemType, "exclam"))
+ pofs = "exclamation";
+ else if (!strcmp(stemType, "indecl"))
+ pofs = "irregular";
+ else if (!strcmp(stemType, "prep"))
+ pofs = "preposition";
+ }
+
+ /* if part of speech found */
+ if (pofs)
+ {
+ const char *ord = alpheiosAttributeLookup(alpheiosPofsOrder, pofs);
+ fprintf(fout, "");
+ xml_write_text(fout, pofs);
+ fprintf(fout, "\n");
+
+ /* if noun or adjective, look for declension */
+ if ((strcmp(pofs, "noun") == 0) ||
+ (strcmp(pofs, "adjective") == 0))
+ {
+ alpheiosDumpFlag("decl",
+ alpheiosDeclNames,
+ stemtype_of(analysis) & DECL_MASK,
+ fout);
+ }
+ }
+
+ return pofs;
+}
+
+/* dump morphological values (except case and gender) */
+void alpheiosDumpMorphology(word_form a_wf, FILE* a_fout)
+{
+ alpheiosDumpFlag("comp", alpheiosComparisonNames, degree_of(a_wf), a_fout);
+ alpheiosDumpFlag("mood", alpheiosMoodNames, mood_of(a_wf), a_fout);
+ alpheiosDumpFlag("num", alpheiosNumberNames, number_of(a_wf), a_fout);
+ alpheiosDumpFlag("pers", alpheiosPersonNames, person_of(a_wf), a_fout);
+ alpheiosDumpFlag("tense", alpheiosTenseNames, tense_of(a_wf), a_fout);
+ alpheiosDumpFlag("voice", alpheiosVoiceNames, voice_of(a_wf), a_fout);
+}
+
+void alpheiosDumpString(
+const char* a_label,
+const char* a_indent,
+gk_string* a_string,
+FILE* a_fout)
+{
+ /* if no content, don't do anything */
+ int i;
+ for (i = 0; i < MORPHFLAG_BYTES; ++i)
+ {
+ if (a_string->gs_morphflags[i])
+ break;
+ }
+ if ((i == MORPHFLAG_BYTES) &&
+ isEmptyForm(a_string->gs_forminfo) &&
+ !a_string->gs_steminfo &&
+ !a_string->gs_derivtype &&
+ !a_string->gs_dialect &&
+ !a_string->gs_geogregion &&
+ !*(a_string->st_domains) &&
+ !*(a_string->gs_gkstring))
+ {
+ return;
+ }
+
+ fprintf(a_fout, "%s<%s>\n", a_indent, a_label);
+ if (!isEmptyForm(a_string->gs_forminfo))
+ {
+ unsigned formval = 0;
+ memcpy(&formval, &a_string->gs_forminfo, sizeof formval);
+ fprintf(a_fout, "%s \n", a_indent, formval);
+ if (a_string->gs_forminfo.f_voice)
+ {
+ fprintf(a_fout, "%s 0%o\n",
+ a_indent,
+ a_string->gs_forminfo.f_voice);
+ }
+ if (a_string->gs_forminfo.f_mood)
+ {
+ fprintf(a_fout, "%s 0%o\n",
+ a_indent,
+ a_string->gs_forminfo.f_mood);
+ }
+ if (a_string->gs_forminfo.f_tense)
+ {
+ fprintf(a_fout, "%s 0%o\n",
+ a_indent,
+ a_string->gs_forminfo.f_tense);
+ }
+ if (a_string->gs_forminfo.f_person)
+ {
+ fprintf(a_fout, "%s 0%o\n",
+ a_indent,
+ a_string->gs_forminfo.f_person);
+ }
+ if (a_string->gs_forminfo.f_number)
+ {
+ fprintf(a_fout, "%s 0%o\n",
+ a_indent,
+ a_string->gs_forminfo.f_number);
+ }
+ if (a_string->gs_forminfo.f_case)
+ {
+ fprintf(a_fout, "%s 0%o\n",
+ a_indent,
+ a_string->gs_forminfo.f_case);
+ }
+ if (a_string->gs_forminfo.f_degree)
+ {
+ fprintf(a_fout, "%s 0%o\n",
+ a_indent,
+ a_string->gs_forminfo.f_degree);
+ }
+ if (a_string->gs_forminfo.f_gender)
+ {
+ fprintf(a_fout, "%s 0%o\n",
+ a_indent,
+ a_string->gs_forminfo.f_gender);
+ }
+ }
+ if (a_string->gs_steminfo)
+ {
+ const char *name = NameOfStemtype(stemtype_of(a_string));
+ fprintf(a_fout, "%s 0%o ",
+ a_indent,
+ a_string->gs_steminfo);
+ xml_write_text(a_fout, name ? name : "");
+ fprintf(a_fout, "\n");
+ }
+ if (a_string->gs_derivtype)
+ {
+ fprintf(a_fout, "%s 0%o\n",
+ a_indent,
+ a_string->gs_derivtype);
+ }
+ if (a_string->gs_dialect)
+ {
+ fprintf(a_fout, "%s 0%o\n",
+ a_indent,
+ a_string->gs_dialect);
+ }
+ if (a_string->gs_geogregion)
+ {
+ fprintf(a_fout, "%s 0%o\n",
+ a_indent,
+ a_string->gs_geogregion);
+ }
+ if (i < MORPHFLAG_BYTES)
+ {
+ fprintf(a_fout, "%s ", a_indent);
+ for (i = 0; i < MORPHFLAG_BYTES; ++i)
+ {
+ if (i > 0)
+ fprintf(a_fout, ",");
+ fprintf(a_fout, "%d", a_string->gs_morphflags[i]);
+ }
+ fprintf(a_fout, "\n");
+ }
+ if (*(a_string->st_domains))
+ {
+ fprintf(a_fout, "%s ", a_indent);
+ xml_write_text(a_fout, a_string->st_domains);
+ fprintf(a_fout, "\n");
+ }
+ if (*(a_string->gs_gkstring))
+ {
+ fprintf(a_fout, "%s ", a_indent);
+ xml_write_text(a_fout, a_string->gs_gkstring);
+ fprintf(a_fout, "\n");
+ }
+ fprintf(a_fout, "%s%s>\n", a_indent, a_label);
+}
+
+void alpheiosDumpFlag(
+const char* a_tag,
+const MorphEntry* a_table,
+long a_flags,
+FILE* a_fout)
+{
+ const char* name = alpheiosMorphLookup(a_table, a_flags);
+ if (name && *name)
+ {
+ fprintf(a_fout, "<%s>", a_tag);
+ xml_write_text(a_fout, name);
+ fprintf(a_fout, "%s>\n", a_tag);
+ }
+}
+
+void alpheiosDumpFlags(
+const char* a_tag,
+const MorphEntry* a_table,
+long a_flags,
+FILE* a_fout)
+{
+ if (!a_flags || !a_table)
+ return;
+
+ char temp[BUFSIZ];
+ *temp = '\0';
+
+ const MorphEntry* nextEntry;
+ for (nextEntry = a_table; nextEntry->d_flags != 0; ++nextEntry)
+ {
+ /* if this entry is contained in flags */
+ if ((nextEntry->d_flags & a_flags) == nextEntry->d_flags)
+ {
+ /* mask out used flags and add to output */
+ a_flags &= ~(nextEntry->d_flags);
+ if (*temp)
+ strcat(temp, " ");
+ strncat(temp, nextEntry->d_name, BUFSIZ - strlen(temp) - 1);
+ }
+ }
+
+ if (*temp)
+ {
+ fprintf(a_fout, "<%s>", a_tag);
+ xml_write_text(a_fout, temp);
+ fprintf(a_fout, "%s>\n", a_tag);
+ }
+}
+
+const char* alpheiosMorphLookup(
+const MorphEntry* a_table,
+long a_flags)
+{
+ if (!a_flags || !a_table)
+ return NULL;
+
+ const MorphEntry* nextEntry;
+ for (nextEntry = a_table; nextEntry->d_flags != 0; ++nextEntry)
+ {
+ if ((nextEntry->d_flags & a_flags) == a_flags)
+ return nextEntry->d_name;
+ }
+
+ return NULL;
+}
+
+const char* alpheiosAttributeLookup(
+const AttributeEntry* a_table,
+const char* a_name)
+{
+ if (!a_name || !a_table)
+ return NULL;
+
+ const AttributeEntry* nextEntry;
+ for (nextEntry = a_table; nextEntry->d_name != NULL; ++nextEntry)
+ {
+ if (strcmp(nextEntry->d_name, a_name) == 0)
+ break;
+ }
+
+ return nextEntry->d_value;
+}
+
+bool isEmptyForm(word_form a_wf)
+{
+ return !a_wf.f_voice &&
+ !a_wf.f_mood &&
+ !a_wf.f_tense &&
+ !a_wf.f_person &&
+ !a_wf.f_number &&
+ !a_wf.f_case &&
+ !a_wf.f_degree &&
+ !a_wf.f_gender;
+}
\ No newline at end of file
diff --git a/src/anal/prntalph.h b/src/anal/prntalph.h
new file mode 100644
index 00000000..a5de5c5e
--- /dev/null
+++ b/src/anal/prntalph.h
@@ -0,0 +1,11 @@
+#ifndef PRNTALPH_H
+#define PRNTALPH_H
+
+#include
+#include
+
+int alpheiosPrintWord(gk_word *gkword, PrntFlags prntflags, FILE *fout);
+void xml_write_text(FILE *f, const char *s);
+const char *get_xml_lang(void);
+
+#endif /* PRNTALPH_H */
\ No newline at end of file
diff --git a/src/anal/prntanal.proto.h b/src/anal/prntanal.proto.h
index 8e81b78d..7abed7e5 100755
--- a/src/anal/prntanal.proto.h
+++ b/src/anal/prntanal.proto.h
@@ -3,6 +3,7 @@
/* prntanal.c */
+int GoodAnals(gk_word *, int);
void PrntAnalyses(gk_word *, PrntFlags, FILE *);
char *anal_buf(void);
void PrntOneAnalysis(gk_analysis *, PrntFlags, FILE *);
diff --git a/src/includes/stemtype.h b/src/includes/stemtype.h
index fa840d89..7b0b9a2a 100755
--- a/src/includes/stemtype.h
+++ b/src/includes/stemtype.h
@@ -21,6 +21,7 @@ typedef unsigned int Stemtype;
#define VERBSTEM (0100)
*/
+#define DECL_MASK (DECL1 | DECL2 | DECL3 | DECL4 | DECL5)
#define DECL1 (0100)
#define DECL2 (0200)
#define DECL3 (0400)
diff --git a/tests/greek_words.txt b/tests/greek_words.txt
index c567b18b..eb5fe876 100644
--- a/tests/greek_words.txt
+++ b/tests/greek_words.txt
@@ -263,3 +263,4 @@ xyzzy
lo/gos42
'qa/non
cu/ndesmos
+e)ti/qh
diff --git a/tests/run_xml_tests.sh b/tests/run_xml_tests.sh
new file mode 100644
index 00000000..467ed765
--- /dev/null
+++ b/tests/run_xml_tests.sh
@@ -0,0 +1,506 @@
+#!/bin/bash
+#
+# XML output test suite for morpheus
+#
+# This is intentionally INDEPENDENT of tests/run_tests.sh and is NOT wired
+# into the Makefile.
+# Run it by hand:
+#
+# bash tests/run_xml_tests.sh --update (re)generate golden baselines
+# bash tests/run_xml_tests.sh run tests against saved baselines
+#
+# Fixtures reused from the existing (tracked) test suite -- not modified:
+# tests/greek_words.txt tests/latin_words.txt tests/greek_probe.txt
+# tests/greek_probe_upper.txt tests/latin_probe_upper.txt
+# tests/greek_probe_noaccent.txt
+#
+# Golden baselines this script creates all end in "_expected.txt", which is
+# already covered by the existing .gitignore rule (tests/*_expected.txt) --
+# no .gitignore changes needed.
+#
+# Conventions (matching tests/run_tests.sh):
+# - A golden-baseline text diff is reported but does NOT fail the run.
+# - A crash, a malformed-XML document, or a violated parity contract
+# DOES fail the run.
+# - Known, already-diagnosed defects get a dedicated XFAIL test so that
+# fixing them shows up as a visible XPASS instead of silently doing
+# nothing.
+#
+set -u
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+MORPHEUS="$PROJECT_DIR/bin/morpheus"
+CRUNCHER="$PROJECT_DIR/bin/cruncher"
+COMPARATOR="$SCRIPT_DIR/xml_vs_nl.py"
+PY=python3
+
+UPDATE=0
+[ "${1:-}" = "--update" ] && UPDATE=1
+
+export MORPHLIB="$PROJECT_DIR/stemlib"
+export PATH="$PROJECT_DIR/bin:$PATH"
+
+PASS=0; FAIL=0; WARN=0; SKIP=0; XFAIL=0; XPASS=0
+
+pass() { echo "PASS: $*"; PASS=$((PASS+1)); }
+fail() { echo "FAIL: $*"; FAIL=$((FAIL+1)); }
+warn() { echo "WARN: $*"; WARN=$((WARN+1)); }
+skip() { echo "SKIP: $*"; SKIP=$((SKIP+1)); }
+xfail() { echo "XFAIL: $* (known issue; expected to fail until fixed)"; XFAIL=$((XFAIL+1)); }
+xpass() { echo "XPASS: $* -- unexpectedly passing now; remove the XFAIL marker for this test"; XPASS=$((XPASS+1)); }
+
+hr() { echo; echo "--- $* ---"; }
+
+print_summary() {
+ echo
+ echo "================================================================"
+ echo " PASS=$PASS FAIL=$FAIL WARN=$WARN SKIP=$SKIP XFAIL=$XFAIL XPASS=$XPASS"
+ echo "================================================================"
+}
+
+finish() {
+ print_summary
+ [ "$FAIL" -gt 0 ] && exit 1
+ exit 0
+}
+
+# ---------------------------------------------------------------------------
+# Well-formedness helper (xmllint if present, else python3 stdlib, else SKIP)
+# ---------------------------------------------------------------------------
+have_wf_checker() {
+ command -v xmllint >/dev/null 2>&1 || command -v "$PY" >/dev/null 2>&1
+}
+
+check_wellformed() {
+ # returns 0 if well-formed, 1 otherwise. Caller must call have_wf_checker first.
+ local f="$1"
+ if command -v xmllint >/dev/null 2>&1; then
+ xmllint --noout "$f" >/dev/null 2>&1
+ return $?
+ fi
+ "$PY" -c '
+import sys, xml.etree.ElementTree as ET
+try:
+ ET.parse(sys.argv[1])
+except Exception:
+ sys.exit(1)
+' "$f"
+ return $?
+}
+
+wf_check() {
+ local label="$1" file="$2"
+ if ! have_wf_checker; then
+ skip "$label wellformed (no xmllint or python3 available)"
+ return
+ fi
+ if check_wellformed "$file"; then
+ pass "$label wellformed"
+ else
+ fail "$label wellformed"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# 1. Build/link smoke tests (5.4)
+# ---------------------------------------------------------------------------
+hr "Build/link smoke tests"
+
+if [ ! -x "$MORPHEUS" ]; then
+ fail "bin/morpheus exists and is executable (build it with 'make')"
+ echo
+ echo "bin/morpheus is not available; skipping the rest of the XML test suite."
+ finish
+fi
+pass "bin/morpheus exists and is executable"
+
+"$MORPHEUS" /tmp/morpheus_smoke_$$ 2>/dev/null
+rc=$?
+rm -f /tmp/morpheus_smoke_$$
+if [ "$rc" -eq 2 ]; then
+ pass "morpheus and several tests will fail."
+ fi
+done
+
+# ---------------------------------------------------------------------------
+# 2. Golden baseline tests (5.1)
+# ---------------------------------------------------------------------------
+hr "Golden baseline tests"
+
+run_morpheus_golden() {
+ local label="$1" flags="$2" input="$3" baseline="$4"
+ local out err
+ out=$(mktemp); err=$(mktemp)
+ "$MORPHEUS" $flags < "$input" > "$out" 2>"$err"
+ local rc=$?
+
+ if [ "$rc" -ge 128 ]; then
+ fail "$label (morpheus crashed / killed, exit=$rc)"
+ sed 's/^/ stderr: /' "$err"
+ rm -f "$out" "$err"
+ return
+ fi
+ rm -f "$err"
+
+ if [ "$UPDATE" -eq 1 ]; then
+ cp "$out" "$baseline"
+ echo "updated: $baseline"
+ rm -f "$out"
+ return
+ fi
+
+ if [ ! -f "$baseline" ]; then
+ skip "$label (no baseline; run 'bash $0 --update')"
+ elif diff -u "$baseline" "$out" > /tmp/golden_diff_$$ 2>&1; then
+ pass "$label matches baseline"
+ else
+ warn "$label differs from baseline (diff below; not a hard failure)"
+ head -40 /tmp/golden_diff_$$
+ rm -f /tmp/golden_diff_$$
+ fi
+
+ wf_check "$label" "$out"
+ rm -f "$out"
+}
+
+GP="$SCRIPT_DIR/greek_probe.txt"
+
+run_morpheus_golden "Greek words (default)" "" "$SCRIPT_DIR/greek_words.txt" "$SCRIPT_DIR/greek_xml_expected.txt"
+run_morpheus_golden "Latin words (-L)" "-L" "$SCRIPT_DIR/latin_words.txt" "$SCRIPT_DIR/latin_xml_expected.txt"
+run_morpheus_golden "Greek -i (dump_analysis)" "-i" "$GP" "$SCRIPT_DIR/greek_xml_probe_i_expected.txt"
+run_morpheus_golden "Greek -S (upper case)" "-S" "$SCRIPT_DIR/greek_probe_upper.txt" "$SCRIPT_DIR/greek_xml_probe_S_expected.txt"
+run_morpheus_golden "Latin -LS (upper case)" "-LS" "$SCRIPT_DIR/latin_probe_upper.txt" "$SCRIPT_DIR/latin_xml_probe_S_expected.txt"
+run_morpheus_golden "Greek -n (no accent)" "-n" "$SCRIPT_DIR/greek_probe_noaccent.txt" "$SCRIPT_DIR/greek_xml_probe_n_expected.txt"
+
+if [ "$UPDATE" -eq 1 ]; then
+ finish
+fi
+
+# ---------------------------------------------------------------------------
+# 3. Regression tests for specific bugs of the original implementation for Alpheios
+# ---------------------------------------------------------------------------
+hr "Flag parsing and argv-handling tests"
+
+# ARGS has "...n:" so bare -n incorrectly swallows the next argv token
+# as its option-argument. Detect this without needing a real argv word: feed
+# -n a word via argv with /dev/null stdin. If -n eats it, the program falls
+# straight through to empty stdin and exits 2 ("No words provided"). If -n
+# is fixed to take no argument, the word is processed from argv and it exits 0.
+test_flag_n_no_arg() {
+ "$MORPHEUS" -n 'lo/gos' /tmp/flag_n_$$ 2>/dev/null
+ local rc=$?
+ rm -f /tmp/flag_n_$$
+ if [ "$rc" -eq 0 ]; then
+ pass "flag-n-arg: -n does not consume the following argv word as its optarg"
+ else
+ fail "flag-n-arg: -n incorrectly requires an argument (ARGS has 'n:'); exit=$rc, expected 0"
+ fi
+}
+test_flag_n_no_arg
+
+# P8: after consuming argv words, morpheus must not fall through and
+# block on stdin. Use a pipe that is held open (never closed, never written
+# to) so a real attempt to read stdin would hang until `timeout` kills it.
+test_p8() {
+ if ! command -v timeout >/dev/null 2>&1; then
+ skip "P8: morpheus does not hang after argv words (timeout(1) unavailable)"
+ return
+ fi
+ local out
+ out=$(mktemp)
+ timeout 5 "$MORPHEUS" 'lo/gos' < <(sleep 30) > "$out" 2>/dev/null
+ local rc=$?
+ if [ "$rc" -eq 124 ]; then
+ fail "P8: morpheus hung reading stdin after processing an argv word (timed out)"
+ elif [ "$rc" -ne 0 ]; then
+ fail "P8: unexpected exit code $rc for argv-word invocation"
+ else
+ local nwords
+ nwords=$(grep -c '' "$out")
+ if [ "$nwords" -eq 1 ]; then
+ pass "P8: morpheus with an argv word exits promptly with exactly one "
+ else
+ fail "P8: expected exactly one element, got $nwords"
+ fi
+ fi
+ rm -f "$out"
+}
+test_p8
+
+# argv words skip the same cleaning (trimdigit) that stdin words get.
+test_argv_cleanup() {
+ local w='lo/gos2'
+ local out_argv out_stdin
+ out_argv=$(mktemp); out_stdin=$(mktemp)
+ "$MORPHEUS" "$w" "$out_argv" 2>/dev/null
+ printf '%s\n' "$w" | "$MORPHEUS" >"$out_stdin" 2>/dev/null
+ if grep -q '' "$out_stdin"; then
+ if grep -q '' "$out_argv"; then
+ pass "argv-cleanup: argv word '$w' is cleaned the same way as the stdin word"
+ else
+ fail "argv-cleanup: argv word '$w' not cleaned like the stdin word (stdin=known, argv=unknown)"
+ fi
+ else
+ skip "argv-cleanup check for '$w': the stdin path itself did not recognize it"
+ fi
+ rm -f "$out_argv" "$out_stdin"
+}
+test_argv_cleanup
+
+# ---------------------------------------------------------------------------
+# 4. Positive parity tests (P1-P5 via comparator, plus P6/P7 directly)
+# ---------------------------------------------------------------------------
+hr "Positive parity tests (P1-P5, P6, P7)"
+
+run_parity_suite() {
+ local label="$1" wordlist="$2" cflags="$3" mflags="$4"
+ if [ ! -x "$CRUNCHER" ]; then
+ skip "$label (bin/cruncher missing)"
+ return
+ fi
+ if ! command -v "$PY" >/dev/null 2>&1; then
+ skip "$label (python3 unavailable)"
+ return
+ fi
+ local out
+ out=$(mktemp)
+ "$PY" "$COMPARATOR" "$wordlist" \
+ --cruncher "$CRUNCHER" --cruncher-flags="$cflags" \
+ --morpheus "$MORPHEUS" --morpheus-flags="$mflags" \
+ > "$out"
+ local rc=$?
+ cat "$out"
+ PASS=$((PASS + $(grep -c '^PASS:' "$out")))
+ FAIL=$((FAIL + $(grep -c '^FAIL:' "$out")))
+ WARN=$((WARN + $(grep -c '^WARN:' "$out")))
+ if [ "$rc" -ne 0 ]; then
+ echo "--- $label: comparator reported hard parity failures (see above) ---"
+ else
+ echo "--- $label: all hard parity checks (P1-P3) passed ---"
+ fi
+ rm -f "$out"
+}
+
+run_parity_suite "Greek parity vs cruncher" "$SCRIPT_DIR/greek_words.txt" "" ""
+run_parity_suite "Latin parity vs cruncher" "$SCRIPT_DIR/latin_words.txt" "-L" "-L"
+
+# P6: determinism
+test_p6() {
+ local out1 out2
+ out1=$(mktemp); out2=$(mktemp)
+ "$MORPHEUS" < "$SCRIPT_DIR/greek_words.txt" > "$out1" 2>/dev/null
+ "$MORPHEUS" < "$SCRIPT_DIR/greek_words.txt" > "$out2" 2>/dev/null
+ if cmp -s "$out1" "$out2"; then
+ pass "P6: morpheus output is byte-identical across repeated runs"
+ else
+ fail "P6: morpheus output differs between repeated runs (non-determinism)"
+ fi
+ rm -f "$out1" "$out2"
+}
+test_p6
+
+# P7: exit codes + well-formedness of the empty-input error document
+test_p7() {
+ "$MORPHEUS" /dev/null 2>/dev/null
+ local rc_empty=$?
+ [ "$rc_empty" -eq 2 ] && pass "P7: empty stdin exits 2" \
+ || fail "P7: empty stdin expected exit 2, got $rc_empty"
+
+ printf 'lo/gos\n' | "$MORPHEUS" >/dev/null 2>/dev/null
+ local rc_ok=$?
+ [ "$rc_ok" -eq 0 ] && pass "P7: normal input exits 0" \
+ || fail "P7: normal input expected exit 0, got $rc_ok"
+
+ if ! have_wf_checker; then
+ skip "P7: empty-input error document wellformed (no checker)"
+ return
+ fi
+ local out
+ out=$(mktemp)
+ "$MORPHEUS" < /dev/null > "$out" 2>/dev/null
+ if check_wellformed "$out"; then
+ pass "P7: empty-input error document is well-formed XML"
+ else
+ fail "P7: empty-input error document is well-formed XML"
+ fi
+ rm -f "$out"
+}
+test_p7
+
+# ---------------------------------------------------------------------------
+# 5. Negative tests -- intentional divergences from cruncher (N1-N8)
+# ---------------------------------------------------------------------------
+hr "Negative tests (intentional divergences from cruncher)"
+
+# N1: vocabulary (long-form case/number, not abbreviations)
+test_n1() {
+ local out
+ out=$(mktemp)
+ printf 'lo/gos\n' | "$MORPHEUS" >"$out" 2>/dev/null
+ if grep -q 'nominative' "$out" \
+ && grep -q 'singular' "$out" \
+ && ! grep -q '>nom<' "$out"; then
+ pass "N1: XML uses long-form vocabulary (nominative/singular), not cruncher's abbreviations"
+ else
+ fail "N1: expected long-form case/number vocabulary for lo/gos"
+ fi
+ wf_check "N1 (lo/gos)" "$out"
+ rm -f "$out"
+}
+test_n1
+
+# N2: pofs refinement (article, sentinel order=0)
+test_n2() {
+ local out
+ out=$(mktemp)
+ printf 'o(\n' | "$MORPHEUS" >"$out" 2>/dev/null
+ if grep -q 'article' "$out"; then
+ pass "N2: XML refines article pofs with sentinel order=0 (cruncher just says 'I')"
+ else
+ fail "N2: expected article for o("
+ fi
+ wf_check "N2 (o()" "$out"
+ rm -f "$out"
+}
+test_n2
+
+# N3: runtime escaping (raw & in , replacing alpheios's pre-escaped table)
+test_n3() {
+ local out
+ out=$(mktemp)
+ printf 'bonus\n' | "$MORPHEUS" -L >"$out" 2>/dev/null
+ if grep -q '1st & 2nd' "$out"; then
+ pass "N3: runtime XML escaping produces literal & in "
+ else
+ fail "N3: expected literal '&' escaping in for bonus"
+ fi
+ wf_check "N3 (bonus)" "$out"
+ rm -f "$out"
+}
+test_n3
+
+# N4: unknown word handling (inline , both languages)
+test_n4() {
+ local out
+ out=$(mktemp)
+ printf 'xyzzy\n' | "$MORPHEUS" >"$out" 2>/dev/null
+ if grep -q 'xyzzy' "$out"; then
+ pass "N4: unknown Greek word emitted inline as inside "
+ else
+ fail "N4: expected inline for xyzzy"
+ fi
+ wf_check "N4 (xyzzy, greek)" "$out"
+ rm -f "$out"
+
+ out=$(mktemp)
+ printf 'xyzzy\n' | "$MORPHEUS" -L >"$out" 2>/dev/null
+ if grep -q 'xyzzy' "$out"; then
+ pass "N4b: unknown Latin word emitted inline as "
+ else
+ fail "N4b: expected inline for xyzzy"
+ fi
+ wf_check "N4b (xyzzy, latin)" "$out"
+ rm -f "$out"
+}
+test_n4
+
+# N5: dialect table change (removal of {"all", ALL_DIAL} unblocks 'prose' etc.)
+test_n5() {
+ if [ ! -f "$SCRIPT_DIR/greek_xml_expected.txt" ]; then
+ skip "N5: dialect table PROSE check (run --update first to create a baseline)"
+ return
+ fi
+ if grep -q '[^<]*[Pp]rose' "$SCRIPT_DIR/greek_xml_expected.txt"; then
+ pass "N5: contains 'prose' somewhere in the Greek fixture output"
+ else
+ warn "N5: no 'prose' dialect observed anywhere in the Greek fixture; add/confirm a word that exercises it"
+ fi
+}
+test_n5
+
+# N6: NULL-safety (no "(null)" leaking into gender/pofs output)
+test_n6() {
+ local out
+ out=$(mktemp)
+ "$MORPHEUS" < "$SCRIPT_DIR/greek_words.txt" >> "$out" 2>/dev/null
+ "$MORPHEUS" -L < "$SCRIPT_DIR/latin_words.txt" >> "$out" 2>/dev/null
+ if grep -qi '(null)' "$out"; then
+ fail "N6: literal '(null)' text found in XML output (NULL-safety regression)"
+ else
+ pass "N6: no '(null)' text in XML output (gender/pofs NULL guards hold)"
+ fi
+ rm -f "$out"
+}
+test_n6
+
+# N7: comment handling
+test_n7() {
+ if ! have_wf_checker; then
+ skip "N7: comment handling wellformedness checks (no checker)"
+ return
+ fi
+
+ local out
+ out=$(mktemp)
+ printf '#hello\nlo/gos\n' | "$MORPHEUS" >"$out" 2>/dev/null
+ local nopen nclose nword
+ nopen=$(grep -c '' "$out")
+ nclose=$(grep -c '' "$out")
+ nword=$(grep -c '' "$out")
+ if [ "$nopen" -eq 1 ] && [ "$nclose" -eq 1 ] && [ "$nword" -eq 1 ] \
+ && check_wellformed "$out"; then
+ pass "N7a: comment-then-word yields a single well-formed document"
+ else
+ fail "N7a: comment-then-word yields a single well-formed document (open=$nopen close=$nclose word=$nword)"
+ fi
+ rm -f "$out"
+
+ out=$(mktemp)
+ printf '#just a comment\n' | "$MORPHEUS" >"$out" 2>/dev/null
+ if check_wellformed "$out"; then
+ pass "N7b: comment-only input produces a well-formed document"
+ else
+ fail "N7b: comment-only input produces a well-formed document"
+ fi
+ rm -f "$out"
+
+ out=$(mktemp)
+ printf '#a--b\n#a-->b\nlo/gos\n' | "$MORPHEUS" >"$out" 2>/dev/null
+ if check_wellformed "$out"; then
+ pass "N7c: pathological comment content (--, -->) stays well-formed"
+ else
+ fail "N7c: pathological comment content (--, -->) stays well-formed"
+ fi
+ rm -f "$out"
+}
+test_n7
+
+# N8: first-token-only semantics (cruncher parity, diverges from alpheios original)
+test_n8() {
+ local out
+ out=$(mktemp)
+ printf 'lo/gos timh/\n' | "$MORPHEUS" >"$out" 2>/dev/null
+ local nform
+ nform=$(grep -c '