aid-installer 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/VERSION +1 -1
- package/bin/aid +162 -22
- package/bin/aid.ps1 +193 -43
- package/dashboard/home.html +41 -4
- package/dashboard/reader/derivation.py +228 -5
- package/dashboard/reader/models.py +22 -0
- package/dashboard/reader/parsers.py +127 -0
- package/dashboard/reader/reader.py +6 -1
- package/dashboard/server/reader.mjs +313 -0
- package/dashboard/server/server.py +12 -0
- package/lib/AidInstallCore.psm1 +247 -53
- package/lib/aid-install-core.sh +170 -13
- package/package.json +9 -6
- package/scripts/vendor.js +0 -98
|
@@ -642,6 +642,301 @@ function deriveKbStatus(kbDir, summaryApproved, summaryPresent, kbBaseline, repo
|
|
|
642
642
|
}
|
|
643
643
|
}
|
|
644
644
|
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
// f007 / task-042: per-doc freshness (byte-parity twin of derivation.py)
|
|
647
|
+
// ---------------------------------------------------------------------------
|
|
648
|
+
|
|
649
|
+
const RE_FM_FENCE = /^---\s*$/;
|
|
650
|
+
const RE_URL_SOURCE = /^[a-z][a-z0-9+.\-]*:\/\//;
|
|
651
|
+
|
|
652
|
+
function isUrlSource(entry) {
|
|
653
|
+
// Twin of Python parsers.is_url_source() and kb-freshness-check.sh is_url().
|
|
654
|
+
return RE_URL_SOURCE.test(entry);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function parseDocFrontmatter(docPath) {
|
|
658
|
+
// Tolerant sources:/approved_at_commit: frontmatter scan for one KB doc.
|
|
659
|
+
// Twin of Python parsers.parse_doc_frontmatter().
|
|
660
|
+
//
|
|
661
|
+
// Returns [approvedAtCommit, sourcesList, sourcesFieldPresent].
|
|
662
|
+
// approvedAtCommit: string or null
|
|
663
|
+
// sourcesList: string[] (items from sources: list)
|
|
664
|
+
// sourcesFieldPresent: boolean (true even if sources: [])
|
|
665
|
+
//
|
|
666
|
+
// Never throws. Handles inline list [a,b] + block list (- a\n - b).
|
|
667
|
+
|
|
668
|
+
let approvedAtCommit = null;
|
|
669
|
+
const sourcesList = [];
|
|
670
|
+
let sourcesFieldPresent = false;
|
|
671
|
+
|
|
672
|
+
let raw;
|
|
673
|
+
try {
|
|
674
|
+
raw = readFileSync(docPath, "utf-8");
|
|
675
|
+
} catch (_) {
|
|
676
|
+
return [null, [], false];
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const lines = raw.split("\n");
|
|
680
|
+
let inFm = false;
|
|
681
|
+
let fmEntered = false;
|
|
682
|
+
let inSourcesBlock = false;
|
|
683
|
+
|
|
684
|
+
for (const line of lines) {
|
|
685
|
+
const stripped = line.replace(/\r$/, "");
|
|
686
|
+
if (RE_FM_FENCE.test(stripped)) {
|
|
687
|
+
if (!fmEntered) {
|
|
688
|
+
inFm = true;
|
|
689
|
+
fmEntered = true;
|
|
690
|
+
continue;
|
|
691
|
+
} else {
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
if (!inFm) {
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (inSourcesBlock) {
|
|
700
|
+
// Block-list item: leading whitespace + '-'
|
|
701
|
+
const mItem = /^[ \t]+-[ \t]*(.*)/.exec(stripped);
|
|
702
|
+
if (mItem) {
|
|
703
|
+
const item = mItem[1].trim().replace(/^['"]|['"]$/g, "");
|
|
704
|
+
if (item) sourcesList.push(item);
|
|
705
|
+
continue;
|
|
706
|
+
} else {
|
|
707
|
+
inSourcesBlock = false;
|
|
708
|
+
// fall through to check this line for other fields
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// approved_at_commit: scalar
|
|
713
|
+
const mAac = /^approved_at_commit:\s*(.*)/.exec(stripped);
|
|
714
|
+
if (mAac) {
|
|
715
|
+
const val = mAac[1].trim().replace(/^['"]|['"]$/g, "");
|
|
716
|
+
approvedAtCommit = val || null;
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// sources: field
|
|
721
|
+
const mSrc = /^sources:\s*(.*)/.exec(stripped);
|
|
722
|
+
if (mSrc) {
|
|
723
|
+
sourcesFieldPresent = true;
|
|
724
|
+
const rest = mSrc[1].trim();
|
|
725
|
+
if (rest === "[]") {
|
|
726
|
+
// Explicit empty inline list: sources: []
|
|
727
|
+
// sourcesFieldPresent already set; list stays empty
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (!rest) {
|
|
731
|
+
// Bare 'sources:' with nothing after -- block list follows
|
|
732
|
+
inSourcesBlock = true;
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
if (rest.startsWith("[")) {
|
|
736
|
+
// Inline list: [a, b, c]
|
|
737
|
+
const inner = rest.replace(/^\[/, "").replace(/\].*$/, "").trim();
|
|
738
|
+
if (inner) {
|
|
739
|
+
for (const item of inner.split(",")) {
|
|
740
|
+
const s = item.trim().replace(/^['"]|['"]$/g, "");
|
|
741
|
+
if (s) sourcesList.push(s);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
// Block list -- following indented lines are items
|
|
747
|
+
inSourcesBlock = true;
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return [approvedAtCommit, sourcesList, sourcesFieldPresent];
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function _readRoutingFields(docPath) {
|
|
756
|
+
// Read kb-category and source frontmatter scalars for doc routing.
|
|
757
|
+
// Returns [kbCategory, sourceField]. Absent fields return "".
|
|
758
|
+
// Twin of Python derivation._read_routing_fields().
|
|
759
|
+
// Never throws.
|
|
760
|
+
|
|
761
|
+
let raw;
|
|
762
|
+
try {
|
|
763
|
+
raw = readFileSync(docPath, "utf-8");
|
|
764
|
+
} catch (_) {
|
|
765
|
+
return ["", ""];
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const lines = raw.split("\n");
|
|
769
|
+
let inFm = false;
|
|
770
|
+
let fmEntered = false;
|
|
771
|
+
let kbCat = "";
|
|
772
|
+
let srcField = "";
|
|
773
|
+
|
|
774
|
+
for (const line of lines) {
|
|
775
|
+
const stripped = line.replace(/\r$/, "");
|
|
776
|
+
if (RE_FM_FENCE.test(stripped)) {
|
|
777
|
+
if (!fmEntered) {
|
|
778
|
+
inFm = true;
|
|
779
|
+
fmEntered = true;
|
|
780
|
+
continue;
|
|
781
|
+
} else {
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (!inFm) break;
|
|
786
|
+
|
|
787
|
+
const mCat = /^kb-category:\s*(.*)/.exec(stripped);
|
|
788
|
+
if (mCat) {
|
|
789
|
+
kbCat = mCat[1].trim().replace(/^['"]|['"]$/g, "");
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
const mSrc = /^source:\s*(.*)/.exec(stripped);
|
|
793
|
+
if (mSrc) {
|
|
794
|
+
srcField = mSrc[1].trim().replace(/^['"]|['"]$/g, "");
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return [kbCat, srcField];
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function _runMergeBaseIsAncestor(repoRoot, cSrc, baseline) {
|
|
803
|
+
// Returns "current" | "suspect" | "unknown".
|
|
804
|
+
// execFileSync throws on non-zero exit; status 1 = NOT ancestor = suspect.
|
|
805
|
+
// Any other error (128 bad object, ENOENT, timeout) = unknown.
|
|
806
|
+
try {
|
|
807
|
+
execFileSync(
|
|
808
|
+
"git",
|
|
809
|
+
["-C", repoRoot, "merge-base", "--is-ancestor", cSrc, baseline],
|
|
810
|
+
{
|
|
811
|
+
timeout: GIT_TIMEOUT_MS,
|
|
812
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
813
|
+
encoding: "utf-8",
|
|
814
|
+
}
|
|
815
|
+
);
|
|
816
|
+
// exit 0 = ancestor/equal = current
|
|
817
|
+
return "current";
|
|
818
|
+
} catch (err) {
|
|
819
|
+
// execFileSync throws with err.status for non-zero exit
|
|
820
|
+
if (err && err.status === 1) {
|
|
821
|
+
// exit 1 = NOT ancestor = source changed after baseline
|
|
822
|
+
return "suspect";
|
|
823
|
+
}
|
|
824
|
+
// exit 128 (bad object), ENOENT (git absent), timeout, etc. -> unknown
|
|
825
|
+
return "unknown";
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const SKIP_NAMES = new Set(["INDEX.md", "README.md", "STATE.md"]);
|
|
830
|
+
|
|
831
|
+
function deriveDocFreshness(kbDir, repoRoot) {
|
|
832
|
+
// f007: Per-doc freshness read for all hand-authored primary/extension KB docs.
|
|
833
|
+
// Twin of Python derivation.derive_doc_freshness().
|
|
834
|
+
//
|
|
835
|
+
// Same algorithm as kb-freshness-check.sh (task-040):
|
|
836
|
+
// - Same doc routing (skip INDEX.md, README.md, STATE.md, meta, generated)
|
|
837
|
+
// - Same absence gate (no approved_at_commit: -> unknown; no/empty sources: -> current)
|
|
838
|
+
// - Same git verbs: git log -1 --format=%H -- <src> + merge-base --is-ancestor
|
|
839
|
+
// - Same fold rule: suspect > current > unknown
|
|
840
|
+
// - Same degrade-to-unknown matrix (any git failure -> unknown, never false suspect)
|
|
841
|
+
//
|
|
842
|
+
// Returns array of {doc, verdict, suspect_sources} sorted by doc path.
|
|
843
|
+
// Never throws. No writes.
|
|
844
|
+
|
|
845
|
+
const results = [];
|
|
846
|
+
|
|
847
|
+
let isDir = false;
|
|
848
|
+
try { isDir = statSync(kbDir).isDirectory(); } catch (_) { isDir = false; }
|
|
849
|
+
if (!isDir) return results;
|
|
850
|
+
|
|
851
|
+
let entries = [];
|
|
852
|
+
try { entries = readdirSync(kbDir); } catch (_) { return results; }
|
|
853
|
+
|
|
854
|
+
// Sort deterministically (same as Python sorted() + bash sort)
|
|
855
|
+
const mdFiles = entries
|
|
856
|
+
.filter(n => n.endsWith(".md") && !n.startsWith("."))
|
|
857
|
+
.sort();
|
|
858
|
+
|
|
859
|
+
for (const name of mdFiles) {
|
|
860
|
+
if (SKIP_NAMES.has(name)) continue;
|
|
861
|
+
|
|
862
|
+
const docPath = join(kbDir, name);
|
|
863
|
+
|
|
864
|
+
// Check routing fields: kb-category and source
|
|
865
|
+
const [kbCat, srcField] = _readRoutingFields(docPath);
|
|
866
|
+
if (kbCat === "meta") continue;
|
|
867
|
+
if (srcField === "generated") continue;
|
|
868
|
+
// Only primary and extension with hand-authored (or absent) source
|
|
869
|
+
if (kbCat !== "primary" && kbCat !== "extension" && kbCat !== "") continue;
|
|
870
|
+
|
|
871
|
+
const rel = name;
|
|
872
|
+
|
|
873
|
+
// Parse frontmatter: approved_at_commit + sources
|
|
874
|
+
const [approvedAtCommit, sourcesList, sourcesFieldPresent] =
|
|
875
|
+
parseDocFrontmatter(docPath);
|
|
876
|
+
|
|
877
|
+
// Absence gate: missing/empty approved_at_commit -> unknown (never suspect)
|
|
878
|
+
if (!approvedAtCommit) {
|
|
879
|
+
results.push({ doc: rel, verdict: "unknown", suspect_sources: [] });
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// sources: absent or empty -> current (nothing to drift against)
|
|
884
|
+
if (!sourcesFieldPresent || sourcesList.length === 0) {
|
|
885
|
+
results.push({ doc: rel, verdict: "current", suspect_sources: [] });
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Per-source staleness checks
|
|
890
|
+
let nCurrent = 0;
|
|
891
|
+
let nSuspect = 0;
|
|
892
|
+
let nUnknown = 0;
|
|
893
|
+
const suspectSources = [];
|
|
894
|
+
|
|
895
|
+
for (const entry of sourcesList) {
|
|
896
|
+
const srcVerdict = _checkSourceNode(entry, approvedAtCommit, repoRoot);
|
|
897
|
+
if (srcVerdict === "current") {
|
|
898
|
+
nCurrent++;
|
|
899
|
+
} else if (srcVerdict === "suspect") {
|
|
900
|
+
nSuspect++;
|
|
901
|
+
suspectSources.push(entry);
|
|
902
|
+
} else {
|
|
903
|
+
nUnknown++;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Fold rule (identical to script and Python twin)
|
|
908
|
+
let verdict;
|
|
909
|
+
if (nSuspect > 0) {
|
|
910
|
+
verdict = "suspect";
|
|
911
|
+
} else if (nCurrent > 0) {
|
|
912
|
+
verdict = "current";
|
|
913
|
+
} else {
|
|
914
|
+
verdict = "unknown";
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
results.push({ doc: rel, verdict, suspect_sources: suspectSources });
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
return results;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function _checkSourceNode(entry, approvedAtCommit, repoRoot) {
|
|
924
|
+
// Node implementation of check_source (twin of Python _check_source).
|
|
925
|
+
// Returns "current" | "suspect" | "unknown".
|
|
926
|
+
|
|
927
|
+
if (isUrlSource(entry)) return "unknown";
|
|
928
|
+
|
|
929
|
+
// Get last-changed commit for this path/glob
|
|
930
|
+
const cSrc = runGitCommand(
|
|
931
|
+
["-C", repoRoot, "log", "-1", "--format=%H", "--", entry],
|
|
932
|
+
null
|
|
933
|
+
);
|
|
934
|
+
if (!cSrc) return "unknown";
|
|
935
|
+
|
|
936
|
+
// merge-base --is-ancestor: exit 0 = current, exit 1 = suspect, other = unknown
|
|
937
|
+
return _runMergeBaseIsAncestor(repoRoot, cSrc, approvedAtCommit);
|
|
938
|
+
}
|
|
939
|
+
|
|
645
940
|
// ---------------------------------------------------------------------------
|
|
646
941
|
// Derivation helpers (mirrors derivation.py)
|
|
647
942
|
// ---------------------------------------------------------------------------
|
|
@@ -1798,6 +2093,11 @@ function _readRepoFull(root) {
|
|
|
1798
2093
|
);
|
|
1799
2094
|
kbState.status = kbStatus;
|
|
1800
2095
|
kbState.kb_baseline = kbBaseline;
|
|
2096
|
+
|
|
2097
|
+
// task-042: per-doc freshness (f007) -- additive; gitFreshnessCheck retained
|
|
2098
|
+
const docFreshness = deriveDocFreshness(loc.kbDir, resolvedRoot);
|
|
2099
|
+
kbState.doc_freshness = docFreshness;
|
|
2100
|
+
kbState.suspect_count = docFreshness.filter(d => d.verdict === "suspect").length;
|
|
1801
2101
|
}
|
|
1802
2102
|
|
|
1803
2103
|
const repoInfo = { project_name: projectName, aid_dir: loc.aidDir, kb_state: kbState };
|
|
@@ -3028,6 +3328,7 @@ function _buildKbStateRef(kb) {
|
|
|
3028
3328
|
// KbStateRef field order (DM-A3 deterministic, task-064):
|
|
3029
3329
|
// retained: summary_approved, last_summary_date, doc_count
|
|
3030
3330
|
// new: status, summary_present, kb_baseline
|
|
3331
|
+
// task-042 additions: doc_freshness, suspect_count
|
|
3031
3332
|
return {
|
|
3032
3333
|
summary_approved: kb.summary_approved,
|
|
3033
3334
|
last_summary_date: kb.last_summary_date,
|
|
@@ -3035,6 +3336,18 @@ function _buildKbStateRef(kb) {
|
|
|
3035
3336
|
status: kb.status !== undefined ? kb.status : KbStatus.unknown,
|
|
3036
3337
|
summary_present: kb.summary_present !== undefined ? kb.summary_present : false,
|
|
3037
3338
|
kb_baseline: _buildKbBaseline(kb.kb_baseline),
|
|
3339
|
+
doc_freshness: Array.isArray(kb.doc_freshness) ? kb.doc_freshness.map(_buildDocFreshness) : [],
|
|
3340
|
+
suspect_count: typeof kb.suspect_count === "number" ? kb.suspect_count : 0,
|
|
3341
|
+
};
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
function _buildDocFreshness(df) {
|
|
3345
|
+
// DocFreshness field order: doc, verdict, suspect_sources
|
|
3346
|
+
// Twin of Python DocFreshness dataclass (models.py, task-042).
|
|
3347
|
+
return {
|
|
3348
|
+
doc: df.doc,
|
|
3349
|
+
verdict: df.verdict,
|
|
3350
|
+
suspect_sources: Array.isArray(df.suspect_sources) ? df.suspect_sources : [],
|
|
3038
3351
|
};
|
|
3039
3352
|
}
|
|
3040
3353
|
|
|
@@ -483,12 +483,22 @@ def _ser_kb_baseline(obj) -> dict | None:
|
|
|
483
483
|
}
|
|
484
484
|
|
|
485
485
|
|
|
486
|
+
def _ser_doc_freshness(obj) -> dict:
|
|
487
|
+
"""Serialize one DocFreshness entry in declared field order (task-042/task-043)."""
|
|
488
|
+
return {
|
|
489
|
+
"doc": obj.doc,
|
|
490
|
+
"verdict": obj.verdict,
|
|
491
|
+
"suspect_sources": list(obj.suspect_sources),
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
|
|
486
495
|
def _ser_kb_state(obj) -> dict | None:
|
|
487
496
|
"""Serialize KbStateRef in declared field order (DM-A3, task-064), or None if absent.
|
|
488
497
|
|
|
489
498
|
Field order (DM-3 deterministic):
|
|
490
499
|
retained: summary_approved, last_summary_date, doc_count
|
|
491
500
|
new (task-064): status, summary_present, kb_baseline
|
|
501
|
+
new (task-042/task-043): doc_freshness, suspect_count
|
|
492
502
|
No schema_version bump (DM-A3).
|
|
493
503
|
"""
|
|
494
504
|
if obj is None:
|
|
@@ -500,6 +510,8 @@ def _ser_kb_state(obj) -> dict | None:
|
|
|
500
510
|
"status": obj.status.value if hasattr(obj.status, "value") else str(obj.status),
|
|
501
511
|
"summary_present": obj.summary_present,
|
|
502
512
|
"kb_baseline": _ser_kb_baseline(obj.kb_baseline),
|
|
513
|
+
"doc_freshness": [_ser_doc_freshness(d) for d in (obj.doc_freshness or [])],
|
|
514
|
+
"suspect_count": obj.suspect_count if isinstance(obj.suspect_count, int) else 0,
|
|
503
515
|
}
|
|
504
516
|
|
|
505
517
|
|