Skip to content

Engineering Wiki Scanner Developer Guide

Ed Mozley edited this page Oct 13, 2026 · 1 revision

πŸ› οΈ Engineering wiki scanner - Developer Guide

How System -> Engineering wiki is built: the scanner that reads FreeITSM's own PHP and JavaScript and writes the Code map, the Markdown renderer that shows it, and the viewer around them. To write comments the scanner understands, see Commenting for the Engineering wiki.

In one paragraph: a PHP class, EwScanner, walks the app, reads every .php file with PHP's own tokenizer (and every .js file line by line), and records what it finds - files, functions, parameters, classes, includes, calls, SQL, traps, request fields and access checks. It keeps that in a manifest so the next scan only re-reads files that changed. From the manifest it writes one Markdown page per file, function, class and database table, plus indexes, under engineering-wiki/code/. The viewer reads those pages through an administrator-only API and renders them with a small Markdown renderer written for the purpose. No AI, no third-party library, no database tables.


1. πŸ“ The files

🟒 new in 3.6.0 Β· πŸ”΅ changed

File What it is
🟒 includes/engineering_wiki/scanner.php EwScanner - list, parse, write. About 1,600 lines; everything in this guide except the viewer lives here
🟒 includes/engineering_wiki/store.php Where pages live: ewSafePath(), ewFile(), ewResolveLink(), ewReadPage(), ewListDir(), ewSearch()
🟒 includes/engineering_wiki/markdown.php EwMarkdown - the GitHub-flavoured renderer. Escapes all HTML
🟒 api/system/engineering-wiki/page.php GET ?page= - one page, rendered
🟒 api/system/engineering-wiki/tree.php GET ?dir= - one folder of the sidebar tree
🟒 api/system/engineering-wiki/search.php GET ?q= - search every page
🟒 api/system/engineering-wiki/scan.php POST - run ~6 seconds of scanning (the Rescan button)
🟒 scripts/engineering-wiki-scan.php The same scan from the command line, to the end. --full forgets the last scan
🟒 system/engineering-wiki/index.php The page: sidebar, tabs, split view
🟒 assets/js/engineering-wiki.js · assets/css/engineering-wiki.css The viewer's behaviour and the GitHub look, light and dark
🟒 engineering-wiki/handbook/*.md Pages written by people - start, comments, standard parameters. Never touched by the scanner
🟒 engineering-wiki/code/**.md Pages written by the scanner. Shipped with each release, rebuilt by a Rescan
🟒 engineering-wiki/code/_scan/ The scanner's state: manifest.json, state.json, pages.json, last.json, lock. Git-ignored
🟒 engineering-wiki/.htaccess · web.config Deny all - the .md files are never served directly
πŸ”΅ includes/db_verify_schema.php Not new, but the scanner reads it for every table and column, comments included

There are no database tables. Everything is files.


2. πŸ” The three phases

A scan is three phases, run in steps under a time budget so no web request runs long:

list   ->  walk the app; queue every file that is new or changed; forget deleted ones
parse  ->  read each queued file; store what it found in the manifest
write  ->  build every page from the manifest; write only those that changed

step() does as much as it can in the time it is given, saves where it got to, and returns. The Rescan button calls the API in a loop, 6 seconds a time, until done is true. The command line calls runAll(), which steps 20 seconds at a time to the end.

public function step(float $seconds = 8.0, bool $fresh = false): array
{
    $this->deadline = microtime(true) + $seconds;
    $this->ensureDirs();
    $lock = fopen($this->stateDir . '/lock', 'c');
    if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
        return $this->report('busy', 'Another scan is running.');
    }
    try {
        $this->loadManifest();
        $this->state = $fresh ? [] : $this->readJson('state.json', []);
        if (empty($this->state['phase'])) $this->state = ['phase' => 'list', 'queue' => [], ...];

        if ($this->state['phase'] === 'list')  $this->phaseList();
        if ($this->state['phase'] === 'parse') $this->phaseParse();
        if ($this->state['phase'] === 'write' && microtime(true) < $this->deadline + 30) $this->phaseWrite();

        $this->saveState();
        return $this->report($this->state['phase'], '');
    } finally {
        flock($lock, LOCK_UN);
        fclose($lock);
    }
}

Plain English:

  • One scan at a time. A non-blocking file lock. A second Rescan, or the command line while the browser is scanning, gets busy back instead of two scans tearing up the same files.
  • Resumable. The queue lives in state.json and is saved every 3 seconds during parsing. Close the tab half way and the next Rescan carries on from the next file.
  • The write phase is not split. It needs every file's parse at once, so once started it runs to the end (it has 30 seconds of grace past the deadline, and set_time_limit(300)). On the full codebase it takes a few seconds.

Phase 1 - what changed

$this->walk($this->app, '', function (string $rel, string $abs) use (&$seen, &$queue) {
    $seen[$rel] = true;
    $mt = filemtime($abs); $sz = filesize($abs);
    $old = $this->manifest['files'][$rel] ?? null;
    if ($old && $old['mtime'] === $mt && $old['size'] === $sz) return;
    if ($old && $old['size'] === $sz && $old['sha1'] === sha1_file($abs)) {      // touched, not changed
        $this->manifest['files'][$rel]['mtime'] = $mt;
        return;
    }
    $queue[] = $rel;
});

Three checks, cheapest first: same modified time and size - skip, without reading the file. Same size but a new time - read it once for the SHA-1, and if the content is identical just note the new time (a git checkout touches every file without changing them). Anything else is queued.

walk() takes .php and .js files and skips:

  • the folders in SKIP_DIRS - .git, vendor, node_modules, engineering-wiki, lang, tests, local, docs, database, uploads, recordings, logs, data, cache, tmp, releases, samples, system-wiki, tinymce
  • hidden folders (starting with a dot)
  • any folder whose .htaccess holds Require all denied or Deny from all - that is how upload folders are marked, so a new one is skipped without anyone remembering to add it
  • *.min.js, and any file over 1.5 MB (bundled or generated, not ours to document)

Phase 2 - read each file

while ($this->state['queue']) {
    if (microtime(true) > $this->deadline) break;
    $rel = array_shift($this->state['queue']);
    $src = (string)file_get_contents($this->app . '/' . $rel);
    $data = substr($rel, -3) === '.js' ? $this->parseJs($src, $rel) : $this->parsePhp($src, $rel);
    $this->manifest['files'][$rel] = ['mtime' => filemtime($abs), 'size' => filesize($abs), 'sha1' => sha1($src), 'data' => $data];
    ...
}

The parse is in sections 4 and 5. It runs on files one at a time and needs nothing from any other file - linking things together waits for phase 3.

Phase 3 - write the pages

Phase 3 loads every file's parse from the manifest and builds indexes across them:

Index Built from Used for
$fnIndex - function name -> where defined every functions[] with no class function pages, links from calls
$classIndex - class -> file, doc, methods classes[] and functions[] with a class class pages
$callers - function -> who calls it every calls[], kept only when the callee is one of ours Called by
$includedBy - file -> files that include it includes[] Included by, and the guard search
$endpointCallers - endpoint -> JS that calls it each JS file's endpoints[] Called from on API pages
$scriptUsers - JS file -> pages that load it <script src> found in PHP's inline HTML Loaded by
$tableUse - table -> readers and writers every sql[] Read by / Written by on table pages

Then it builds every page as a string, and writes only the pages whose content differs from what is on disk:

foreach ($pages as $page => $md) {
    $path = ewRoot() . '/' . $page . '.md';
    if (is_file($path) && file_get_contents($path) === $md) continue;
    if (!is_dir(dirname($path))) @mkdir(dirname($path), 0775, true);
    file_put_contents($path, $md);
    $written++;
}
$previous = $this->readJson('pages.json', []);
foreach ($previous as $page) {
    if (!isset($pages[$page])) { @unlink(ewRoot() . '/' . $page . '.md'); }
}
$this->writeJson('pages.json', array_keys($pages));

pages.json is the list of pages the last scan wrote, so a page for a deleted function or file is removed - and only pages the scanner made, never a handbook page. Empty folders are tidied afterwards.

Writing only what changed matters for git: a one-line comment change produces a diff of the two or three pages it touches, not 4,800.


3. πŸ—‚οΈ The manifest

engineering-wiki/code/_scan/manifest.json holds one entry per file. A real entry, trimmed:

{
  "version": 10,
  "files": {
    "includes/ticket_snooze.php": {
      "mtime": 1791932701,
      "size": 14517,
      "sha1": "43bd35e52c15700787134fbe0bd2f5bd446bc1da",
      "data": {
        "kind": "php",
        "doc": {
          "summary": "Snoozing a ticket - \"I can't do anything with this until Thursday.\"",
          "body": "WHY THIS EXISTS\n---------------\nHalf of a service desk's que...",
          "tags": []
        },
        "functions": [
          {
            "name": "snoozeSchemaReady", "class": null, "line": 56,
            "params": [
              {"type": "PDO", "name": "$conn", "default": "", "ref": false, "variadic": false, "note": ""}
            ],
            "return": "bool",
            "doc": {"summary": "Does this database have the snooze columns yet?", "body": "The inbox list and its folder counts are ...", "tags": []},
            "static": false, "visibility": ""
          }
        ],
        "classes": [],
        "includes": ["includes/functions.php", "includes/timezone.php"],
        "calls": [["snoozeSchemaReady", "snoozeHiddenSql", 124], ["snoozeSchemaReady", "snoozeOnlySql", 137]],
        "sql": [
          {"op": "SHOW", "reads": ["tickets"], "writes": [], "line": 60, "fn": "snoozeSchemaReady", "note": null}
        ],
        "traps": [],
        "inputs": [],
        "guards": [],
        "scripts": [],
        "session": [],
        "lines": 327
      }
    }
  }
}
Key Holds
mtime, size, sha1 The fingerprint phase 1 compares against
doc The file comment: summary (first sentence-paragraph), body (the rest), tags ([name, value] pairs)
functions One per declaration - class methods too, with class set
calls [callee, caller, line], each callee/caller pair once. caller is null at the top level
sql One per SQL string - see section 6
inputs GET:page, POST:id... - the request fields read with a literal key
guards [function, first argument, inside function] for every require*() check
session The $_SESSION keys read
scripts <script src> files the page loads, from the app root

version is the class constant VERSION. Bump it whenever the shape of the parse changes - a manifest from another version is thrown away and every file read again:

private function loadManifest(): void
{
    $m = $this->readJson('manifest.json', null);
    if (is_array($m) && ($m['version'] ?? 0) === self::VERSION) $this->manifest = $m;
}

Important

The manifest is never shipped. Modified times differ per checkout, so a shipped manifest would be wrong on every install. A fresh install's first Rescan reads everything once (around a minute); after that the SHA-1 check stops a mere change of time counting as a change.

The state files are written through a temporary file and a rename, so a request that reads one while a scan is writing never sees half a file:

file_put_contents($f . '.tmp', json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
@rename($f . '.tmp', $f) || (@unlink($f) && @rename($f . '.tmp', $f));   // Windows will not rename over a file

4. 🐘 Reading PHP

parsePhp() makes one pass over token_get_all($src). The tokenizer is PHP's own, so strings, comments, heredocs and ?> HTML are never confused with code - something a regex reading the source could not promise.

It keeps a little state as it walks:

Variable Tracks
$depth Brace depth - every {, ${ and {$ up, every } down
$class, $classDepth The class being read, and the depth it ends at
$fnStack [name, depth] for each function body being read - so a call or query knows which function it is in
$pendingDoc The last /** */ block, waiting for the declaration it describes
$lineRun The // lines (or /* */ block) directly above what comes next
$sawCode Has any real code been seen yet? Decides what the file comment is

A helper finds the next or previous significant token - skipping whitespace and comments - which most of the checks lean on:

$sig = function (int $i, int $dir) use ($tokens, $n) {
    for ($k = $i + $dir; $k >= 0 && $k < $n; $k += $dir) {
        $t = $tokens[$k];
        if (is_array($t) && in_array($t[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) continue;
        return $k;
    }
    return -1;
};

The file comment

if ($tid === T_DOC_COMMENT) {
    if (!$sawCode && $d['doc'] === null) {
        // TRAP: the first doc block in a file is ALWAYS the file's description. ...
        $d['doc'] = $this->parseDoc($t[1]);
        $k = $i + 1;
        while ($k < $n && $id($tokens[$k]) === T_WHITESPACE) $k++;
        $nt = $k < $n ? $id($tokens[$k]) : null;
        if (in_array($nt, [T_FUNCTION, T_CLASS, T_INTERFACE, T_TRAIT, T_ABSTRACT, T_FINAL, T_READONLY], true)) $pendingDoc = $t[1];
        continue;
    }
    $pendingDoc = $t[1];
}

The first /** */ before any code is the file's. If a declaration follows it with only whitespace between, it is also offered to that declaration - the common "one header, then the class" shape. If there is no /** */ header, the // lines or /* */ block at the top of the file are used instead (lineDoc()), once code starts.

Which comment belongs to a declaration

A declaration takes $pendingDoc if there is one, else the $lineRun above it. Both are cleared by:

  • a blank line (whitespace holding two or more newlines) - for $lineRun
  • any token that is not a modifier - public, private, protected, static, abstract, final, readonly or an attribute keep the comment alive, so a doc block above public static function still reaches the function

That is why a stray statement or an unrelated comment between a doc block and its function loses the description.

Functions and their parameters

if ($tid === T_FUNCTION) {
    $nx = $sig($i, 1);
    if ($nx >= 0 && $id($tokens[$nx]) === '&') $nx = $sig($nx, 1);
    if ($nx < 0 || $id($tokens[$nx]) !== T_STRING) { $pendingDoc = null; continue; }      // a closure
    $name = $tokens[$nx][1];
    [$params, $close] = $this->params($tokens, $sig($nx, 1));
    ...
    $d['functions'][] = ['name' => $name, 'class' => $class, 'line' => $line, 'params' => $params, 'return' => trim($ret),
                         'doc' => $pendingDoc ? $this->parseDoc($pendingDoc) : ($lineRun ? $this->lineDoc($lineRun) : null), ...];
    // find its body, and push it on $fnStack so what follows knows where it is
}

function followed by a name is a declaration; followed by ( it is a closure and skipped. params() walks from ( to the matching ), splitting on commas at depth 1 only (so a default of [1, 2] is one parameter), and collects each one's type, &, ..., name and default. Constructor-promoted properties (public int $x) have their visibility dropped. A // comment on a parameter's line becomes its note - after the comma it belongs to the parameter just finished, before it to the current one.

Classes, interfaces and traits are found the same way, ignoring Foo::class and new class.

Calls, includes, request fields, guards

Seen Recorded
name( - not after ->, ?->, new, function or :: calls[] - a plain function call
Class::name( calls[] as Class::name
require / include (_once too) includes[], if the path can be worked out - see below
$_GET['x'], $_POST['x'], $_REQUEST['x'] with a literal key inputs[] as GET:x
$_SESSION['x'] session[]
A call to require*Admin*, require*Access*, require*Cap* ... guards[], with its first argument and the function it is in
<script src="...js"> in inline HTML scripts[]

Include paths are worked out from the tokens, not guessed. String pieces, __DIR__ and dirname(__DIR__, n) are understood; anything with a variable or another function is left out rather than recorded wrong:

require_once __DIR__ . '/../includes/leases.php';      // -> includes/leases.php
require_once dirname(__DIR__, 2) . '/config.php';      // -> config.php
require_once '../../../includes/admin_api_guard.php';  // -> includes/admin_api_guard.php (from the file's folder)
require_once $base . '/x.php';                         // -> (not recorded)

Doc comments into summary, body and tags

private function parseDoc(string $raw): array
{
    $raw = preg_replace(['#^/\*\*+#', '#\*+/$#'], '', $raw);
    // A one-line comment can carry its tags on the same line ("/** Summary. @return int */").
    if (strpos(trim($raw), "\n") === false) {
        $raw = preg_replace('/(\S)\s+(@(?:param|return|reads|writes|see|wiki|input|output|throws)\b)/', "\$1\n\$2", $raw);
    }
    $lines = array_map(fn($l) => preg_replace('/^\s*\*( ?)/', '', rtrim($l)), explode("\n", $raw));
    foreach ($lines as $l) {
        if (preg_match('/^\s*@(\w+)\s*(.*)$/', $l, $m)) { $tags[] = [strtolower($m[1]), trim($m[2])]; continue; }
        if ($tags && trim($l) !== '' && preg_match('/^\s{2,}/', $l)) { $tags[count($tags) - 1][1] .= ' ' . trim($l); continue; }   // a tag running on
        if ($tags && trim($l) === '') continue;
        $text[] = $l;
    }
    ...
    $summary = first paragraph, on one line;  $body = the rest
}
  • The summary is the first paragraph, squashed onto one line. The body is everything after the first blank line.
  • A line starting @word is a tag. An indented line after a tag continues it.
  • Tags are only split out of the middle of a line in a one-line comment, so prose such as "add @param lines" in a longer comment is never mistaken for a tag.

lineDoc() turns a run of // lines into the same shape by building a doc comment from them. It drops decorative rules and banners - // ----, // ====, /* ═══ KNOWLEDGE ═══ */ - and returns nothing when the run is only a TRAP:, because a warning is not a description.

Traps

Any comment holding TRAP: is a trap. A // trap takes the // lines straight after it as well, so a three-line warning is kept whole. trapText() strips the comment markers, keeps the text from TRAP: to the end of its paragraph and puts it on one line. Each trap records its line and the function it is in.


5. 🟨 Reading JavaScript

PHP has a tokenizer; JavaScript in PHP does not, so parseJs() reads JavaScript line by line. That is deliberately less ambitious: it finds functions and the endpoints they call, not calls between functions.

Each trimmed line is tested against the shapes the codebase uses, in order:

// function name(a)          export / async / generator allowed
'/^(?:export\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(([^)]*)\)?/'
// name = function (a)   const name = function   window.name = function   this.name = function
'/^(?:(?:const|let|var)\s+)?((?:window|this|self|[A-Za-z_$][\w$]*)\.)?([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?function\b[^(]*\(([^)]*)\)?/'
// const name = (a) =>   const name = a =>
'/^(?:(?:const|let|var)\s+)?((?:window|this|self)\.)?([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:\(([^)]*)\)|([A-Za-z_$][\w$]*))\s*=>/'
// name: function (a)        in an object literal
'/^([A-Za-z_$][\w$]*)\s*:\s*(?:async\s+)?function\s*\(([^)]*)\)?/'
// name(a) {                 a class method - but not if (, for (, while (... and not a call taking a callback
'/^(?:static\s+)?(?:async\s+)?([A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*\{\s*$/'

The comment above a function is whatever comment the previous lines held: a /* */ block, or a run of // lines. A blank line or any other line clears it.

Endpoints are any string that holds api/.../x.php, plus the common shape where an api/ folder is held in a constant:

const API_BASE = '../api/tickets/';
fetch(API_BASE + 'save.php', ...)          // -> api/tickets/save.php
if (preg_match_all('#(?:const|let|var)\s+([A-Z_][A-Z0-9_]*)\s*=\s*[\'"]([^\'"]*api/[^\'"]*)[\'"]#', $src, $bm)) ...
if (preg_match_all('#[\'"`]([^\'"`\s]*api/[\w/.-]+\.php)#', $src, $em)) ...

Leading ../ are dropped, and the path counts only if it is a file the scan actually found.


6. πŸ—„οΈ SQL

Every string literal - single-quoted, double-quoted with {$interpolation} joined back together, or a heredoc - goes through sqlOf(). A string is SQL when it starts with a SQL verb, or contains FROM table / JOIN table:

if (!preg_match('/^\s*\(?\s*(SELECT|INSERT|UPDATE|DELETE|REPLACE|CREATE|ALTER|DROP|TRUNCATE|SHOW|WITH)\b/i', $s, $m)
    && !preg_match('/\b(FROM|JOIN)\s+`?[a-z_][a-z0-9_]*`?\s/i', $s)) return null;

The second test catches a query built in pieces, where the string holding FROM is not the one holding SELECT.

Reads are every table after FROM or JOIN. Writes depend on the verb:

Statement Recorded as
INSERT INTO t (a, b) VALUES ... insert t (a, b)
INSERT INTO t SET ... / INSERT ... SELECT insert t
UPDATE t x JOIN ... SET x.a = ?, b = NOW() WHERE ... update t (a, b) - the columns before each =, between SET and WHERE
DELETE FROM t ... / DELETE x FROM t x ... delete t (and not a read of t)
CREATE/ALTER/DROP/TRUNCATE TABLE t Schema upkeep - shown on the file, but not counted as a use of the table's data

For example:

UPDATE lease_assets la
  JOIN lease_schedules ls ON ls.id = la.schedule_id
   SET la.end_date = ls.end_date, la.updated_datetime = UTC_TIMESTAMP()
 WHERE ls.id = ?

is recorded as update lease_assets (end_date, updated_datetime) and a read of lease_schedules.

Only names that are real tables in includes/db_verify_schema.php are linked or counted, so a FROM in an English sentence, or a derived table alias, never invents a table.

The query's purpose comes from a // SQL: comment. Every one in the file is collected first by line, then a query looks for a note on its own line, or one or two lines above:

$note = $sqlNotes[$line] ?? $sqlNotes[$line - 1] ?? $sqlNotes[$line - 2] ?? null;

What the scanner cannot see: a table name held in a variable ("SELECT * FROM $table"), and SQL built by a helper in another file. @reads / @writes on the function fill that gap by hand. When a function's tags name a table that none of its own SQL touches, its page shows a caution - either the comment is stale or the SQL is built elsewhere.

The schema

Table pages come from includes/db_verify_schema.php - the same array Database Verification uses to create tables, so it is always the truth about columns. The scanner requires it for the definitions, then tokenizes it a second time for the comments, which require cannot see. Comments written like this, for example:

// --- Leasing (3.5.0) ----------------------------------------
// An asset on a schedule; its own start/end when they differ from the schedule's.
'lease_assets' => [
    'id'          => 'INT NOT NULL AUTO_INCREMENT',
    'outcome_id'  => 'INT NULL',            // lease_outcomes.id; NULL = still on lease
    ...
],

A comment above a table is the table's note. A comment above a column, or after it on the same line, is the column's note. A // --- Heading --- banner resets what is pending, so a section heading is never mistaken for the next table's description.


7. πŸ” Who can call an endpoint

For every file under api/, the page shows how it is protected. guardText() decides, in this order:

if (basename($rel)[0] === '_') return 'Helper - included by other endpoints, not called directly';
if (strpos($rel, 'api/v1/') === 0) return 'REST API key';
$g = $this->guardsOf($d, '');                // the file's own checks
// none? follow its includes, up to 3 hops, skipping config.php and includes/functions.php
...
if (in_array('analyst_id', $sess, true)) return 'Signed-in analyst (session check)';
if (in_array('ss_user_id', $sess, true)) return 'Signed-in portal user (session check)';
return 'Public - no sign-in check found (...)';

guardsOf() turns what parsing found into words:

Found Shown as
require_once '.../includes/admin_api_guard.php' Administrators only
requireAdmin...() Administrators only
requireModuleAccess('tickets') Module access: tickets
requireCapability(Caps::ASSETS_EDIT) Permission: Caps::ASSETS_EDIT
a check found in an include ... (via api/assets/_bootstrap.php)

A check inside a function is ignored here - it guards whoever calls that function, not the file.

The following of includes is what makes shared bootstraps work: an endpoint whose only line of protection is require '_bootstrap.php' is shown with the bootstrap's check. When nothing is found the page says Public - correct for sign-in pages, webhooks and token links, and a prompt to look at the code for anything else. The Endpoints page lists every endpoint with its guard, so a missing check stands out.

Note

This is a reading aid, not a security control. It reports what the code appears to do; the code is the check.


8. πŸ“„ The pages it writes

Page Path Holds
Home code/index Counts and the way in
File code/files/<path> Summary; for an API file the endpoint box (URL, guard, inputs, callers); functions and classes; Database; includes / included by / loaded by; traps
Function code/functions/<name> Signature, Parameters, returns, reads/writes, SQL, callers, traps, @see, @wiki box
Class code/classes/<name> Its doc and every method
Table code/tables/<name> Columns with notes; Written by; Read by
Indexes code/functions/index etc. A-Z lists with summaries
Endpoints code/endpoints Every API file, its guard and its callers
Traps code/traps Every TRAP: in the codebase

Pages link to each other with ordinary relative Markdown links from the wiki root, e.g. [leases](/code/tables/leases.md). Text from comments goes through prose(), which puts $variables, name() and @tags in code style so an underscore or asterisk in them does not turn into italics, and through cell() before going into a table, which escapes |.

A parameter's Meaning comes from, in order: its @param text, then a comment on its own line, then standardParam() - a short list of names that mean the same everywhere, each checked against its type so $conn only counts when it is a PDO:

case '$conn':      if ($type === 'PDO' || $type === '') $m = 'The database connection.'; break;
case '$analystId': if ($type === 'int') $m = 'The analyst doing this - normally the one signed in.'; break;
case '$ctx':       if ($type === 'ActorContext') $m = 'Who is acting, which companies they may reach, ui or api, and their language.'; break;
case '$apiKey':    if ($api && $type === 'array') $m = 'The API key that authenticated the call - its scopes and company limit.'; break;
...
return $m === null ? '' : $m . ' *([standard](/handbook/standard-parameters.md))*';

@wiki tags, and any github.com/edmozley/freeitsm/wiki/... link already written in a comment, become a box at the top of the page linking out to this wiki. Only the link - the GitHub wiki's text is never copied into FreeITSM.


9. πŸ–₯️ Showing a page

The store and the API

All four API files include admin_api_guard.php - the whole wiki is administrators only, like the rest of System. A page path from a request goes through ewSafePath() and then ewFile():

function ewSafePath(string $p): ?string
{
    $p = trim(str_replace('\\', '/', $p), '/');
    $p = preg_replace('/\.md$/', '', $p);
    if ($p === '' || strlen($p) > 300) return null;
    if (!preg_match('#^[A-Za-z0-9_][A-Za-z0-9_./-]*$#', $p)) return null;
    if (strpos($p, '..') !== false || strpos($p, '//') !== false) return null;
    $section = explode('/', $p)[0];
    if (!in_array($section, EW_SECTIONS, true)) return null;     // handbook or code
    return $p;
}

A fixed character set, no .., one of the two sections - and then ewFile() resolves the real path and checks it still starts with the wiki folder, so a symlink cannot lead out either. The .md files themselves sit behind .htaccess / web.config deny-all rules and are never served directly.

Search reads every .md file and scores it: 10 per word found in the title or path, 1 per word in the body, a small bonus for the handbook; every word must match somewhere. With around 4,800 pages that is fast enough without an index.

The renderer

EwMarkdown is about 500 lines and covers the GitHub-flavoured subset the pages use: headings with GitHub anchors, emphasis, code, links, [[wiki links]], quotes and alerts (> [!NOTE], TIP, IMPORTANT, WARNING, CAUTION), lists and task lists, tables with alignment, fenced code with light highlighting for PHP, SQL, JS, JSON and bash, and rules.

Warning

It never passes HTML through. Every character of text is escaped. Generated pages quote code comments, and a comment is not trusted input - if <script> in a comment reached the page as HTML, it would run in an administrator's browser. Anything that needs markup gets a Markdown form instead.

Links are handed to a resolver from the store. A link to a .md file becomes <a href="?page=..." data-ew-page="...">, which the viewer opens in a tab; anything with a scheme opens in a new browser window.

The viewer

assets/js/engineering-wiki.js gives the page its browser feel:

  • Tabs - click a link to open it in the current tab; Ctrl+click, middle-click or Open in new tab for another. Middle-click on a tab closes it. Each tab has its own back and forward history.
  • Split view - two panes side by side, each with its own tabs.
  • Right-click menu on links, tabs and the sidebar - Open, Open in new tab, Open beside (the other pane), Copy link.
  • Sidebar - the tree, loaded folder by folder from tree.php, because the code map is thousands of pages.
  • Rescan - calls scan.php in a loop with a progress bar until it reports done.
  • The address bar holds ?page=, so any page can be bookmarked or sent to a colleague.

10. πŸš€ Releasing

Before a release, refresh the Code map from the command line so the shipped pages match the code:

php scripts/engineering-wiki-scan.php
done   1/1 files
Done in 5.0s: 1783 files read, 1 changed, 4852 pages (10 written).

--full forgets the manifest and reads everything again - use it after changing the scanner itself if VERSION was not bumped. Commit the changed pages under engineering-wiki/code/ with the release. _scan/ is git-ignored.

On an install, the shipped pages are there from the start. A Rescan only matters when code on that install has changed.


11. πŸͺ€ Traps

Trap Why
Bump VERSION when the parse changes shape Otherwise old manifest entries, in the old shape, are reused for every unchanged file
Never ship _scan/ Modified times are per checkout - a shipped manifest is wrong everywhere
The first doc block is always the file's It used to go to the next declaration, and files with a proper header showed no summary
The renderer escapes everything Comments are quoted into pages; any HTML pass-through is script in an admin's browser
Every path from a request goes through ewSafePath() then ewFile() Same three checks, in the same order, everywhere
walk() skips folders whose .htaccess denies all That is how upload folders are marked; documenting a user's uploaded .php would be worse than useless
Write only changed pages A rescan after a one-line change must not touch 4,800 files in git
/** */ inside a comment's text */ ends the comment early - in PHP that is usually a parse error, not just a lost description

12. 🧰 Extending it

  • A new kind of fact about a file - add it to $d in parsePhp(), use it in a page builder, bump VERSION.
  • A new tag - parseDoc() already keeps every @word; read it from $doc['tags'] in the page builder. Add it to the one-liner split list if it may appear on one line.
  • A new standard parameter - add a case to standardParam() with a type check, and a row to engineering-wiki/handbook/standard-parameters.md.
  • A new guard function - if its name matches require...Admin/Access/Cap... it is picked up already. Otherwise add it to the pattern in parsePhp() and to guardsOf().
  • A new page type - build its Markdown in a ...Page() method and $put() it in phaseWrite(). pages.json will take care of deleting it when it stops being produced.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally